From a443eb66e962a5889aae1c9431028ad8a098c970 Mon Sep 17 00:00:00 2001 From: Mythmon Date: Mon, 10 Oct 2016 16:14:56 -0700 Subject: [PATCH] Bug 1308656 - Add shield-recipe-client as system add-on r=Gijs,rhelmer MozReview-Commit-ID: KNTGKOFXDlH --HG-- extra : rebase_source : 5b7ac9e5a1c004b1123b852e7b59729357a1dae8 --- browser/extensions/moz.build | 1 + .../shield-recipe-client/bootstrap.js | 102 ++++++ .../shield-recipe-client/data/EventEmitter.js | 60 +++ .../shield-recipe-client/install.rdf.in | 24 ++ .../extensions/shield-recipe-client/jar.mn | 9 + .../lib/CleanupManager.jsm | 21 ++ .../lib/EnvExpressions.jsm | 65 ++++ .../shield-recipe-client/lib/Heartbeat.jsm | 346 ++++++++++++++++++ .../shield-recipe-client/lib/NormandyApi.jsm | 99 +++++ .../lib/NormandyDriver.jsm | 141 +++++++ .../shield-recipe-client/lib/RecipeRunner.jsm | 162 ++++++++ .../shield-recipe-client/lib/Sampling.jsm | 81 ++++ .../lib/SandboxManager.jsm | 65 ++++ .../shield-recipe-client/lib/Storage.jsm | 134 +++++++ .../extensions/shield-recipe-client/moz.build | 22 ++ .../node_modules/jexl/LICENSE.txt | 19 + .../node_modules/jexl/lib/Jexl.js | 225 ++++++++++++ .../node_modules/jexl/lib/Lexer.js | 244 ++++++++++++ .../jexl/lib/evaluator/Evaluator.js | 153 ++++++++ .../jexl/lib/evaluator/handlers.js | 159 ++++++++ .../node_modules/jexl/lib/grammar.js | 66 ++++ .../node_modules/jexl/lib/parser/Parser.js | 188 ++++++++++ .../node_modules/jexl/lib/parser/handlers.js | 210 +++++++++++ .../node_modules/jexl/lib/parser/states.js | 154 ++++++++ .../shield-recipe-client/test/.eslintrc.js | 16 + .../shield-recipe-client/test/TestUtils.jsm | 21 ++ .../shield-recipe-client/test/browser.ini | 5 + .../test/browser_EventEmitter.js | 92 +++++ .../test/browser_Heartbeat.js | 188 ++++++++++ .../test/browser_Storage.js | 37 ++ .../test/browser_driver_uuids.js | 26 ++ .../test/browser_env_expressions.js | 56 +++ .../client/debugger/test/mochitest/head.js | 3 +- layout/tools/reftest/reftest-preferences.js | 1 + testing/profiles/prefs_general.js | 3 +- testing/talos/talos/config.py | 2 + .../talos/talos/xtalos/xperf_whitelist.json | 1 + testing/xpcshell/head.js | 3 +- 38 files changed, 3200 insertions(+), 4 deletions(-) create mode 100644 browser/extensions/shield-recipe-client/bootstrap.js create mode 100644 browser/extensions/shield-recipe-client/data/EventEmitter.js create mode 100644 browser/extensions/shield-recipe-client/install.rdf.in create mode 100644 browser/extensions/shield-recipe-client/jar.mn create mode 100644 browser/extensions/shield-recipe-client/lib/CleanupManager.jsm create mode 100644 browser/extensions/shield-recipe-client/lib/EnvExpressions.jsm create mode 100644 browser/extensions/shield-recipe-client/lib/Heartbeat.jsm create mode 100644 browser/extensions/shield-recipe-client/lib/NormandyApi.jsm create mode 100644 browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm create mode 100644 browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm create mode 100644 browser/extensions/shield-recipe-client/lib/Sampling.jsm create mode 100644 browser/extensions/shield-recipe-client/lib/SandboxManager.jsm create mode 100644 browser/extensions/shield-recipe-client/lib/Storage.jsm create mode 100644 browser/extensions/shield-recipe-client/moz.build create mode 100644 browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt create mode 100644 browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js create mode 100644 browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js create mode 100644 browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js create mode 100644 browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js create mode 100644 browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js create mode 100644 browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js create mode 100644 browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js create mode 100644 browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js create mode 100644 browser/extensions/shield-recipe-client/test/.eslintrc.js create mode 100644 browser/extensions/shield-recipe-client/test/TestUtils.jsm create mode 100644 browser/extensions/shield-recipe-client/test/browser.ini create mode 100644 browser/extensions/shield-recipe-client/test/browser_EventEmitter.js create mode 100644 browser/extensions/shield-recipe-client/test/browser_Heartbeat.js create mode 100644 browser/extensions/shield-recipe-client/test/browser_Storage.js create mode 100644 browser/extensions/shield-recipe-client/test/browser_driver_uuids.js create mode 100644 browser/extensions/shield-recipe-client/test/browser_env_expressions.js diff --git a/browser/extensions/moz.build b/browser/extensions/moz.build index 9dee8913b54c..9ce3943aac99 100644 --- a/browser/extensions/moz.build +++ b/browser/extensions/moz.build @@ -10,6 +10,7 @@ DIRS += [ 'pdfjs', 'pocket', 'webcompat', + 'shield-recipe-client', ] # Only include the following system add-ons if building Aurora or Nightly diff --git a/browser/extensions/shield-recipe-client/bootstrap.js b/browser/extensions/shield-recipe-client/bootstrap.js new file mode 100644 index 000000000000..9afa3b7c5db5 --- /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; + } + } +} diff --git a/browser/extensions/shield-recipe-client/data/EventEmitter.js b/browser/extensions/shield-recipe-client/data/EventEmitter.js new file mode 100644 index 000000000000..bcbf1770eaac --- /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); + }, + }; +}; diff --git a/browser/extensions/shield-recipe-client/install.rdf.in b/browser/extensions/shield-recipe-client/install.rdf.in new file mode 100644 index 000000000000..f852537f49af --- /dev/null +++ b/browser/extensions/shield-recipe-client/install.rdf.in @@ -0,0 +1,24 @@ + + +#filter substitution + + + + shield-recipe-client@mozilla.org + 2 + true + false + 1.0.0 + Shield Recipe Client + Client to download and run recipes for SHIELD, Heartbeat, etc. + true + + + + {ec8030f7-c20a-464f-9b0e-13a3a9e97384} + @MOZ_APP_VERSION@ + @MOZ_APP_MAXVERSION@ + + + + diff --git a/browser/extensions/shield-recipe-client/jar.mn b/browser/extensions/shield-recipe-client/jar.mn new file mode 100644 index 000000000000..f29105e65702 --- /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/*) diff --git a/browser/extensions/shield-recipe-client/lib/CleanupManager.jsm b/browser/extensions/shield-recipe-client/lib/CleanupManager.jsm new file mode 100644 index 000000000000..b2ebae69c433 --- /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(); + } + }, +}; diff --git a/browser/extensions/shield-recipe-client/lib/EnvExpressions.jsm b/browser/extensions/shield-recipe-client/lib/EnvExpressions.jsm new file mode 100644 index 000000000000..d233554dd9cc --- /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); + }, +}; diff --git a/browser/extensions/shield-recipe-client/lib/Heartbeat.jsm b/browser/extensions/shield-recipe-client/lib/Heartbeat.jsm new file mode 100644 index 000000000000..8742c347c6e3 --- /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; + } +}; diff --git a/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm b/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm new file mode 100644 index 000000000000..2bf97b2111af --- /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()); + }, +}; diff --git a/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm b/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm new file mode 100644 index 000000000000..edb68e5082a5 --- /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}`); + }, + }; +}; diff --git a/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm b/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm new file mode 100644 index 000000000000..798c91ac5a95 --- /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")); + }), +}; diff --git a/browser/extensions/shield-recipe-client/lib/Sampling.jsm b/browser/extensions/shield-recipe-client/lib/Sampling.jsm new file mode 100644 index 000000000000..f0632957106e --- /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}`); + }); + }, +}; diff --git a/browser/extensions/shield-recipe-client/lib/SandboxManager.jsm b/browser/extensions/shield-recipe-client/lib/SandboxManager.jsm new file mode 100644 index 000000000000..7cc75c3fd757 --- /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; +} diff --git a/browser/extensions/shield-recipe-client/lib/Storage.jsm b/browser/extensions/shield-recipe-client/lib/Storage.jsm new file mode 100644 index 000000000000..8cb2d59426a9 --- /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, + }); + }, +}; diff --git a/browser/extensions/shield-recipe-client/moz.build b/browser/extensions/shield-recipe-client/moz.build new file mode 100644 index 000000000000..891b139e3811 --- /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'] diff --git a/browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt b/browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt new file mode 100644 index 000000000000..e86cb4730df5 --- /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 diff --git a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js new file mode 100644 index 000000000000..d516b67e8d98 --- /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: }} 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; diff --git a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js new file mode 100644 index 000000000000..c5590e86779a --- /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} 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} 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: , + * [name]: , + * value: , + * raw: + * } + * + * 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} 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; diff --git a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js new file mode 100644 index 000000000000..c63dacd23987 --- /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} arr An array of expression strings to be evaluated + * @returns {Promise>} 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} 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; diff --git a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js new file mode 100644 index 000000000000..6ff219f9d363 --- /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: , 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: , 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: , [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: }} 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: , 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: , 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); + }); +}; diff --git a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js new file mode 100644 index 000000000000..4b6838a88065 --- /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; }} +}; diff --git a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js new file mode 100644 index 000000000000..07693d5c0850 --- /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: }} 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: }>} 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: }} 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: }} 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: }} node A node of the AST on which to set a new + * parent + * @param {{type: }} 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; diff --git a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js new file mode 100644 index 000000000000..3f98e4124b05 --- /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: }} 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: }} 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: }} 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: }} 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: }} 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: }} 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: }} 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: }} 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: }} ast The subexpression tree + */ +exports.subExpression = function(ast) { + this._placeAtCursor(ast); +}; + +/** + * Handles a completed alternate subexpression of a ternary operator. + * @param {{type: }} ast The subexpression tree + */ +exports.ternaryEnd = function(ast) { + this._cursor.alternate = ast; +}; + +/** + * Handles a completed consequent subexpression of a ternary operator. + * @param {{type: }} 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: }} 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: }} token A token object + */ +exports.unaryOp = function(token) { + this._placeAtCursor({ + type: 'UnaryExpression', + operator: token.value + }); +}; diff --git a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js new file mode 100644 index 000000000000..cb9afce6006e --- /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 + } +}; diff --git a/browser/extensions/shield-recipe-client/test/.eslintrc.js b/browser/extensions/shield-recipe-client/test/.eslintrc.js new file mode 100644 index 000000000000..2901cac2e92a --- /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, + } +}; diff --git a/browser/extensions/shield-recipe-client/test/TestUtils.jsm b/browser/extensions/shield-recipe-client/test/TestUtils.jsm new file mode 100644 index 000000000000..1dd5ce409283 --- /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()); + }; + }, +}; diff --git a/browser/extensions/shield-recipe-client/test/browser.ini b/browser/extensions/shield-recipe-client/test/browser.ini new file mode 100644 index 000000000000..07ce5a896d4a --- /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] diff --git a/browser/extensions/shield-recipe-client/test/browser_EventEmitter.js b/browser/extensions/shield-recipe-client/test/browser_EventEmitter.js new file mode 100644 index 000000000000..ea10ffb8d3ff --- /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)); +}); diff --git a/browser/extensions/shield-recipe-client/test/browser_Heartbeat.js b/browser/extensions/shield-recipe-client/test/browser_Heartbeat.js new file mode 100644 index 000000000000..133709b085ff --- /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)); +}); diff --git a/browser/extensions/shield-recipe-client/test/browser_Storage.js b/browser/extensions/shield-recipe-client/test/browser_Storage.js new file mode 100644 index 000000000000..681b023b2ef1 --- /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); +}); diff --git a/browser/extensions/shield-recipe-client/test/browser_driver_uuids.js b/browser/extensions/shield-recipe-client/test/browser_driver_uuids.js new file mode 100644 index 000000000000..3461c528159a --- /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)); +}); diff --git a/browser/extensions/shield-recipe-client/test/browser_env_expressions.js b/browser/extensions/shield-recipe-client/test/browser_env_expressions.js new file mode 100644 index 000000000000..f84ab75d53b5 --- /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"); +}); diff --git a/devtools/client/debugger/test/mochitest/head.js b/devtools/client/debugger/test/mochitest/head.js index 1f9d38b82d15..c7241d354242 100644 --- a/devtools/client/debugger/test/mochitest/head.js +++ b/devtools/client/debugger/test/mochitest/head.js @@ -1,4 +1,4 @@ -/* -*- 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/ */ @@ -1348,4 +1348,3 @@ function* initWorkerDebugger(TAB_URL, WORKER_URL) { return {client, tab, tabClient, workerClient, toolbox, gDebugger}; } - diff --git a/layout/tools/reftest/reftest-preferences.js b/layout/tools/reftest/reftest-preferences.js index 07a8d82f1acb..c109f1d1964a 100644 --- a/layout/tools/reftest/reftest-preferences.js +++ b/layout/tools/reftest/reftest-preferences.js @@ -62,6 +62,7 @@ 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); diff --git a/testing/profiles/prefs_general.js b/testing/profiles/prefs_general.js index 6382842b6887..ab00322f092f 100644 --- a/testing/profiles/prefs_general.js +++ b/testing/profiles/prefs_general.js @@ -303,8 +303,9 @@ 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); diff --git a/testing/talos/talos/config.py b/testing/talos/talos/config.py index 59b6123d3f1b..67cedceb761e 100644 --- a/testing/talos/talos/config.py +++ b/testing/talos/talos/config.py @@ -143,6 +143,8 @@ DEFAULTS = dict( '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, diff --git a/testing/talos/talos/xtalos/xperf_whitelist.json b/testing/talos/talos/xtalos/xperf_whitelist.json index 310c3dd3569c..2befe75bc95f 100644 --- a/testing/talos/talos/xtalos/xperf_whitelist.json +++ b/testing/talos/talos/xtalos/xperf_whitelist.json @@ -14,6 +14,7 @@ "{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}, diff --git a/testing/xpcshell/head.js b/testing/xpcshell/head.js index 74fd482cf032..ba564f9efa5b 100644 --- a/testing/xpcshell/head.js +++ b/testing/xpcshell/head.js @@ -1608,7 +1608,6 @@ try { prefs.setBoolPref("geo.provider.testing", true); } } catch (e) { } - // We need to avoid hitting the network with certain components. try { if (runningInParent) { @@ -1619,6 +1618,8 @@ try { 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"); }