new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/actions/BaseAction.jsm
@@ -0,0 +1,138 @@
+/* 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/. */
+
+ChromeUtils.defineModuleGetter(this, "LogManager", "resource://normandy/lib/LogManager.jsm");
+ChromeUtils.defineModuleGetter(this, "Uptake", "resource://normandy/lib/Uptake.jsm");
+ChromeUtils.defineModuleGetter(this, "PoliciesValidator", "resource:///modules/policies/PoliciesValidator.jsm");
+
+var EXPORTED_SYMBOLS = ["BaseAction"];
+
+/**
+ * Base class for local actions.
+ *
+ * This should be subclassed. Subclasses must implement _run() for
+ * per-recipe behavior, and may implement _preExecution and _finalize
+ * for actions to be taken once before and after recipes are run.
+ *
+ * Other methods should be overridden with care, to maintain the life
+ * cycle events and error reporting implemented by this class.
+ */
+class BaseAction {
+ constructor() {
+ this.finalized = false;
+ this.failed = false;
+ this.log = LogManager.getLogger(`action.${this.name}`);
+
+ try {
+ this._preExecution();
+ } catch (err) {
+ this.failed = true;
+ this.log.error(`Could not initialize action ${this.name}: ${err}`);
+ Uptake.reportAction(this.name, Uptake.ACTION_PRE_EXECUTION_ERROR);
+ }
+ }
+
+ get schema() {
+ return {
+ type: "object",
+ properties: {},
+ };
+ }
+
+ // Gets the name of the action. Does not necessarily match the
+ // server slug for the action.
+ get name() {
+ return this.constructor.name;
+ }
+
+ /**
+ * Action specific pre-execution behavior should be implemented
+ * here. It will be called once per execution session.
+ */
+ _preExecution() {
+ // Does nothing, may be overridden
+ }
+
+ /**
+ * Execute the per-recipe behavior of this action for a given
+ * recipe. Reports Uptake telemetry for the execution of the recipe.
+ *
+ * @param {Recipe} recipe
+ * @throws If this action has already been finalized.
+ */
+ async runRecipe(recipe) {
+ if (this.finalized) {
+ throw new Error("Action has already been finalized");
+ }
+
+ if (this.failed) {
+ Uptake.reportRecipe(recipe.id, Uptake.RECIPE_ACTION_DISABLED);
+ this.log.warn(`Skipping recipe ${recipe.name} because ${this.name} failed during preExecution.`);
+ return;
+ }
+
+ let [valid, validatedArguments] = PoliciesValidator.validateAndParseParameters(recipe.arguments, this.schema);
+ if (!valid) {
+ Cu.reportError(new Error(`Arguments do not match schema. arguments: ${JSON.stringify(recipe.arguments)}. schema: ${JSON.stringify(this.schema)}`));
+ Uptake.reportRecipe(recipe.id, Uptake.RECIPE_EXECUTION_ERROR);
+ return;
+ }
+
+ recipe.arguments = validatedArguments;
+
+ let status = Uptake.RECIPE_SUCCESS;
+ try {
+ await this._run(recipe);
+ } catch (err) {
+ this.log.error(`Could not execute recipe ${recipe.name}: ${err}`);
+ status = Uptake.RECIPE_EXECUTION_ERROR;
+ }
+ Uptake.reportRecipe(recipe.id, status);
+ }
+
+ /**
+ * Action specific recipe behavior must be implemented here. It
+ * will be executed once for reach recipe, being passed the recipe
+ * as a parameter.
+ */
+ async _run(recipe) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Finish an execution session. After this method is called, no
+ * other methods may be called on this method, and all relevant
+ * recipes will be assumed to have been seen.
+ */
+ async finalize() {
+ if (this.finalized) {
+ throw new Error("Action has already been finalized");
+ }
+
+ if (this.failed) {
+ this.log.info(`Skipping post-execution hook for ${this.name} due to earlier failure.`);
+ return;
+ }
+
+ let status = Uptake.ACTION_SUCCESS;
+ try {
+ this._finalize();
+ } catch (err) {
+ status = Uptake.ACTION_POST_EXECUTION_ERROR;
+ this.log.info(`Could not run postExecution hook for ${this.name}: ${err.message}`);
+ } finally {
+ this.finalized = true;
+ Uptake.reportAction(this.name, status);
+ }
+ }
+
+ /**
+ * Action specific post-execution behavior should be implemented
+ * here. It will be executed once after all recipes have been
+ * processed.
+ */
+ _finalize() {
+ // Does nothing, may be overridden
+ }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/actions/ConsoleLog.jsm
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
+ChromeUtils.defineModuleGetter(this, "ActionSchemas", "resource://normandy/actions/schemas/index.js");
+
+var EXPORTED_SYMBOLS = ["ConsoleLog"];
+
+class ConsoleLog extends BaseAction {
+ get schema() {
+ return ActionSchemas.consoleLog;
+ }
+
+ async _run(recipe) {
+ this.log.info(recipe.arguments.message);
+ }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/actions/schemas/README.md
@@ -0,0 +1,5 @@
+# Normandy Action Argument Schemas
+
+This is a collection of schemas describing the arguments expected by Normandy
+actions. It's primary purpose is to be used in the Normandy server and Delivery
+Console to validate data and provide better user interactions.
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/actions/schemas/index.js
@@ -0,0 +1,23 @@
+var EXPORTED_SYMBOLS = ["ActionSchemas"];
+
+const ActionSchemas = {
+ consoleLog: {
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "title": "Log a message to the console",
+ "type": "object",
+ "required": [
+ "message"
+ ],
+ "properties": {
+ "message": {
+ "description": "Message to log to the console",
+ "type": "string",
+ "default": ""
+ }
+ }
+ }
+};
+
+if (this.exports) {
+ this.exports = ActionSchemas;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/actions/schemas/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "mozilla-normandy-action-argument-schemas",
+ "version": "0.1.0",
+ "description": "Schemas for Normandy action arguments",
+ "main": "index.js",
+ "author": "Michael Cooper <mcooper@mozilla.com>",
+ "license": "MPL-2.0"
+}
--- a/toolkit/components/normandy/jar.mn
+++ b/toolkit/components/normandy/jar.mn
@@ -2,14 +2,17 @@
# 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/.
toolkit.jar:
% resource normandy %res/normandy/
res/normandy/Normandy.jsm (./Normandy.jsm)
res/normandy/lib/ (./lib/*)
res/normandy/skin/ (./skin/*)
+ res/normandy/actions/BaseAction.jsm (./actions/BaseAction.jsm)
+ res/normandy/actions/ConsoleLog.jsm (./actions/ConsoleLog.jsm)
+ res/normandy/actions/schemas/index.js (./actions/schemas/index.js)
% resource normandy-content %res/normandy/content/ contentaccessible=yes
res/normandy/content/ (./content/*)
% resource normandy-vendor %res/normandy/vendor/ contentaccessible=yes
res/normandy/vendor/ (./vendor/*)
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/lib/ActionsManager.jsm
@@ -0,0 +1,148 @@
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ActionSandboxManager: "resource://normandy/lib/ActionSandboxManager.jsm",
+ ConsoleLog: "resource://normandy/actions/ConsoleLog.jsm",
+ NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
+ Uptake: "resource://normandy/lib/Uptake.jsm",
+});
+
+var EXPORTED_SYMBOLS = ["ActionsManager"];
+
+const log = LogManager.getLogger("recipe-runner");
+
+/**
+ * A class to manage the actions that recipes can use in Normandy.
+ *
+ * This includes both remote and local actions. Remote actions
+ * implementations are fetched from the Normandy server; their
+ * lifecycles are managed by `normandy/lib/ActionSandboxManager.jsm`.
+ * Local actions have their implementations packaged in the Normandy
+ * client, and manage their lifecycles internally.
+ */
+class ActionsManager {
+ constructor() {
+ this.finalized = false;
+ this.remoteActionSandboxes = {};
+
+ this.localActions = {
+ "console-log": new ConsoleLog(),
+ };
+ }
+
+ async fetchRemoteActions() {
+ const actions = await NormandyApi.fetchActions();
+
+ for (const action of actions) {
+ // Skip actions with local implementations
+ if (action.name in this.localActions) {
+ continue;
+ }
+
+ try {
+ const implementation = await NormandyApi.fetchImplementation(action);
+ const sandbox = new ActionSandboxManager(implementation);
+ sandbox.addHold("ActionsManager");
+ this.remoteActionSandboxes[action.name] = sandbox;
+ } catch (err) {
+ log.warn(`Could not fetch implementation for ${action.name}: ${err}`);
+
+ let status;
+ if (/NetworkError/.test(err)) {
+ status = Uptake.ACTION_NETWORK_ERROR;
+ } else {
+ status = Uptake.ACTION_SERVER_ERROR;
+ }
+ Uptake.reportAction(action.name, status);
+ }
+ }
+ }
+
+ async preExecution() {
+ // Local actions run pre-execution hooks implicitly
+
+ for (const [actionName, manager] of Object.entries(this.remoteActionSandboxes)) {
+ try {
+ await manager.runAsyncCallback("preExecution");
+ manager.disabled = false;
+ } catch (err) {
+ log.error(`Could not run pre-execution hook for ${actionName}:`, err.message);
+ manager.disabled = true;
+ Uptake.reportAction(actionName, Uptake.ACTION_PRE_EXECUTION_ERROR);
+ }
+ }
+ }
+
+ async runRecipe(recipe) {
+ let actionName = recipe.action;
+
+ if (actionName in this.localActions) {
+ log.info(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
+ const action = this.localActions[actionName];
+ await action.runRecipe(recipe);
+
+ } else if (actionName in this.remoteActionSandboxes) {
+ let status;
+ const manager = this.remoteActionSandboxes[recipe.action];
+
+ if (manager.disabled) {
+ log.warn(
+ `Skipping recipe ${recipe.name} because ${recipe.action} failed during pre-execution.`
+ );
+ status = Uptake.RECIPE_ACTION_DISABLED;
+ } else {
+ try {
+ log.info(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
+ await manager.runAsyncCallback("action", recipe);
+ status = Uptake.RECIPE_SUCCESS;
+ } catch (e) {
+ e.message = `Could not execute recipe ${recipe.name}: ${e.message}`;
+ Cu.reportError(e);
+ status = Uptake.RECIPE_EXECUTION_ERROR;
+ }
+ }
+ Uptake.reportRecipe(recipe.id, status);
+
+ } else {
+ log.error(
+ `Could not execute recipe ${recipe.name}:`,
+ `Action ${recipe.action} is either missing or invalid.`
+ );
+ Uptake.reportRecipe(recipe.id, Uptake.RECIPE_INVALID_ACTION);
+ }
+ }
+
+ async finalize() {
+ if (this.finalized) {
+ throw new Error("ActionsManager has already been finalized");
+ }
+ this.finalized = true;
+
+ // Finalize local actions
+ for (const action of Object.values(this.localActions)) {
+ action.finalize();
+ }
+
+ // Run post-execution hooks for remote actions
+ for (const [actionName, manager] of Object.entries(this.remoteActionSandboxes)) {
+ // Skip if pre-execution failed.
+ if (manager.disabled) {
+ log.info(`Skipping post-execution hook for ${actionName} due to earlier failure.`);
+ continue;
+ }
+
+ try {
+ await manager.runAsyncCallback("postExecution");
+ Uptake.reportAction(actionName, Uptake.ACTION_SUCCESS);
+ } catch (err) {
+ log.info(`Could not run post-execution hook for ${actionName}:`, err.message);
+ Uptake.reportAction(actionName, Uptake.ACTION_POST_EXECUTION_ERROR);
+ }
+ }
+
+ // Nuke sandboxes
+ Object.values(this.remoteActionSandboxes)
+ .forEach(manager => manager.removeHold("ActionsManager"));
+ }
+}
--- a/toolkit/components/normandy/lib/RecipeRunner.jsm
+++ b/toolkit/components/normandy/lib/RecipeRunner.jsm
@@ -6,37 +6,30 @@
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "timerManager",
"@mozilla.org/updates/timer-manager;1",
"nsIUpdateTimerManager");
-ChromeUtils.defineModuleGetter(this, "Preferences", "resource://gre/modules/Preferences.jsm");
-ChromeUtils.defineModuleGetter(this, "Storage",
- "resource://normandy/lib/Storage.jsm");
-ChromeUtils.defineModuleGetter(this, "NormandyDriver",
- "resource://normandy/lib/NormandyDriver.jsm");
-ChromeUtils.defineModuleGetter(this, "FilterExpressions",
- "resource://normandy/lib/FilterExpressions.jsm");
-ChromeUtils.defineModuleGetter(this, "NormandyApi",
- "resource://normandy/lib/NormandyApi.jsm");
-ChromeUtils.defineModuleGetter(this, "SandboxManager",
- "resource://normandy/lib/SandboxManager.jsm");
-ChromeUtils.defineModuleGetter(this, "ClientEnvironment",
- "resource://normandy/lib/ClientEnvironment.jsm");
-ChromeUtils.defineModuleGetter(this, "CleanupManager",
- "resource://normandy/lib/CleanupManager.jsm");
-ChromeUtils.defineModuleGetter(this, "ActionSandboxManager",
- "resource://normandy/lib/ActionSandboxManager.jsm");
-ChromeUtils.defineModuleGetter(this, "AddonStudies",
- "resource://normandy/lib/AddonStudies.jsm");
-ChromeUtils.defineModuleGetter(this, "Uptake",
- "resource://normandy/lib/Uptake.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.jsm",
+ Storage: "resource://normandy/lib/Storage.jsm",
+ NormandyDriver: "resource://normandy/lib/NormandyDriver.jsm",
+ FilterExpressions: "resource://normandy/lib/FilterExpressions.jsm",
+ NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
+ SandboxManager: "resource://normandy/lib/SandboxManager.jsm",
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
+ CleanupManager: "resource://normandy/lib/CleanupManager.jsm",
+ AddonStudies: "resource://normandy/lib/AddonStudies.jsm",
+ Uptake: "resource://normandy/lib/Uptake.jsm",
+ ActionsManager: "resource://normandy/lib/ActionsManager.jsm",
+});
Cu.importGlobalProperties(["fetch"]);
var EXPORTED_SYMBOLS = ["RecipeRunner"];
const log = LogManager.getLogger("recipe-runner");
const TIMER_NAME = "recipe-client-addon-run";
const PREF_CHANGED_TOPIC = "nsPref:changed";
@@ -207,119 +200,45 @@ var RecipeRunner = {
status = Uptake.RUNNER_NETWORK_ERROR;
} else if (e instanceof NormandyApi.InvalidSignatureError) {
status = Uptake.RUNNER_INVALID_SIGNATURE;
}
Uptake.reportRunner(status);
return;
}
- const actionSandboxManagers = await this.loadActionSandboxManagers();
- Object.values(actionSandboxManagers).forEach(manager => manager.addHold("recipeRunner"));
-
- // Run pre-execution hooks. If a hook fails, we don't run recipes with that
- // action to avoid inconsistencies.
- for (const [actionName, manager] of Object.entries(actionSandboxManagers)) {
- try {
- await manager.runAsyncCallback("preExecution");
- manager.disabled = false;
- } catch (err) {
- log.error(`Could not run pre-execution hook for ${actionName}:`, err.message);
- manager.disabled = true;
- Uptake.reportAction(actionName, Uptake.ACTION_PRE_EXECUTION_ERROR);
- }
- }
+ const actions = new ActionsManager();
+ await actions.fetchRemoteActions();
+ await actions.preExecution();
// Evaluate recipe filters
const recipesToRun = [];
for (const recipe of recipes) {
if (await this.checkFilter(recipe)) {
recipesToRun.push(recipe);
}
}
// Execute recipes, if we have any.
if (recipesToRun.length === 0) {
log.debug("No recipes to execute");
} else {
for (const recipe of recipesToRun) {
- const manager = actionSandboxManagers[recipe.action];
- let status;
- if (!manager) {
- log.error(
- `Could not execute recipe ${recipe.name}:`,
- `Action ${recipe.action} is either missing or invalid.`
- );
- status = Uptake.RECIPE_INVALID_ACTION;
- } else if (manager.disabled) {
- log.warn(
- `Skipping recipe ${recipe.name} because ${recipe.action} failed during pre-execution.`
- );
- status = Uptake.RECIPE_ACTION_DISABLED;
- } else {
- try {
- log.info(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
- await manager.runAsyncCallback("action", recipe);
- status = Uptake.RECIPE_SUCCESS;
- } catch (err) {
- log.error(`Could not execute recipe ${recipe.name}: ${err}`);
- status = Uptake.RECIPE_EXECUTION_ERROR;
- }
- }
-
- Uptake.reportRecipe(recipe.id, status);
+ actions.runRecipe(recipe);
}
}
- // Run post-execution hooks
- for (const [actionName, manager] of Object.entries(actionSandboxManagers)) {
- // Skip if pre-execution failed.
- if (manager.disabled) {
- log.info(`Skipping post-execution hook for ${actionName} due to earlier failure.`);
- continue;
- }
-
- try {
- await manager.runAsyncCallback("postExecution");
- Uptake.reportAction(actionName, Uptake.ACTION_SUCCESS);
- } catch (err) {
- log.info(`Could not run post-execution hook for ${actionName}:`, err.message);
- Uptake.reportAction(actionName, Uptake.ACTION_POST_EXECUTION_ERROR);
- }
- }
-
- // Nuke sandboxes
- Object.values(actionSandboxManagers).forEach(manager => manager.removeHold("recipeRunner"));
+ await actions.finalize();
// Close storage connections
await AddonStudies.close();
Uptake.reportRunner(Uptake.RUNNER_SUCCESS);
},
- async loadActionSandboxManagers() {
- const actions = await NormandyApi.fetchActions();
- const actionSandboxManagers = {};
- for (const action of actions) {
- try {
- const implementation = await NormandyApi.fetchImplementation(action);
- actionSandboxManagers[action.name] = new ActionSandboxManager(implementation);
- } catch (err) {
- log.warn(`Could not fetch implementation for ${action.name}:`, err);
-
- let status = Uptake.ACTION_SERVER_ERROR;
- if (/NetworkError/.test(err)) {
- status = Uptake.ACTION_NETWORK_ERROR;
- }
- Uptake.reportAction(action.name, status);
- }
- }
- return actionSandboxManagers;
- },
-
getFilterContext(recipe) {
return {
normandy: Object.assign(ClientEnvironment.getEnvironment(), {
recipe: {
id: recipe.id,
arguments: recipe.arguments,
},
}),
--- a/toolkit/components/normandy/test/browser/browser.ini
+++ b/toolkit/components/normandy/test/browser/browser.ini
@@ -3,23 +3,26 @@ support-files =
action_server.sjs
fixtures/normandy.xpi
head = head.js
[browser_about_preferences.js]
# Skip this test when FHR/Telemetry aren't available.
skip-if = !healthreport || !telemetry
[browser_about_studies.js]
skip-if = true # bug 1442712
+[browser_action_ConsoleLog.js]
[browser_ActionSandboxManager.js]
+[browser_ActionsManager.js]
[browser_Addons.js]
[browser_AddonStudies.js]
+[browser_BaseAction.js]
[browser_CleanupManager.js]
[browser_ClientEnvironment.js]
[browser_EventEmitter.js]
[browser_FilterExpressions.js]
[browser_Heartbeat.js]
[browser_LogManager.js]
[browser_Normandy.js]
[browser_NormandyDriver.js]
[browser_PreferenceExperiments.js]
[browser_RecipeRunner.js]
[browser_ShieldPreferences.js]
-[browser_Storage.js]
+[browser_Storage.js]
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_ActionsManager.js
@@ -0,0 +1,313 @@
+"use strict";
+
+ChromeUtils.import("resource://normandy/lib/ActionsManager.jsm", this);
+ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this);
+ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
+
+// It should only fetch implementations for actions that don't exist locally
+decorate_task(
+ withStub(NormandyApi, "fetchActions"),
+ withStub(NormandyApi, "fetchImplementation"),
+ async function(fetchActionsStub, fetchImplementationStub) {
+ const remoteAction = {name: "remote-action"};
+ const localAction = {name: "local-action"};
+ fetchActionsStub.resolves([remoteAction, localAction]);
+ fetchImplementationStub.callsFake(async () => "");
+
+ const manager = new ActionsManager();
+ manager.localActions = {"local-action": {}};
+ await manager.fetchRemoteActions();
+
+ is(fetchActionsStub.callCount, 1, "action metadata should be fetched");
+ Assert.deepEqual(
+ fetchImplementationStub.getCall(0).args,
+ [remoteAction],
+ "only the remote action's implementation should be fetched",
+ );
+ },
+);
+
+// Test life cycle methods for remote actions
+decorate_task(
+ withStub(Uptake, "reportAction"),
+ withStub(Uptake, "reportRecipe"),
+ async function(reportActionStub, reportRecipeStub) {
+ let manager = new ActionsManager();
+ const recipe = {id: 1, action: "test-remote-action-used"};
+
+ const sandboxManagerUsed = {
+ removeHold: sinon.stub(),
+ runAsyncCallback: sinon.stub(),
+ };
+ const sandboxManagerUnused = {
+ removeHold: sinon.stub(),
+ runAsyncCallback: sinon.stub(),
+ };
+ manager.remoteActionSandboxes = {
+ "test-remote-action-used": sandboxManagerUsed,
+ "test-remote-action-unused": sandboxManagerUnused
+ };
+ manager.localActions = {};
+
+ await manager.preExecution();
+ await manager.runRecipe(recipe);
+ await manager.finalize();
+
+ Assert.deepEqual(
+ sandboxManagerUsed.runAsyncCallback.args,
+ [
+ ["preExecution"],
+ ["action", recipe],
+ ["postExecution"],
+ ],
+ "The expected life cycle events should be called on the used sandbox action manager",
+ );
+ Assert.deepEqual(
+ sandboxManagerUnused.runAsyncCallback.args,
+ [
+ ["preExecution"],
+ ["postExecution"],
+ ],
+ "The expected life cycle events should be called on the unused sandbox action manager",
+ );
+ Assert.deepEqual(
+ sandboxManagerUsed.removeHold.args,
+ [["ActionsManager"]],
+ "ActionsManager should remove holds on the sandbox managers during finalize.",
+ );
+ Assert.deepEqual(
+ sandboxManagerUnused.removeHold.args,
+ [["ActionsManager"]],
+ "ActionsManager should remove holds on the sandbox managers during finalize.",
+ );
+
+ Assert.deepEqual(reportActionStub.args, [
+ ["test-remote-action-used", Uptake.ACTION_SUCCESS],
+ ["test-remote-action-unused", Uptake.ACTION_SUCCESS],
+ ]);
+ Assert.deepEqual(reportRecipeStub.args, [[recipe.id, Uptake.RECIPE_SUCCESS]]);
+ },
+);
+
+// Test life cycle for remote action that fails in pre-step
+decorate_task(
+ withStub(Uptake, "reportAction"),
+ withStub(Uptake, "reportRecipe"),
+ async function(reportActionStub, reportRecipeStub) {
+ let manager = new ActionsManager();
+ const recipe = {id: 1, action: "test-remote-action-broken"};
+
+ const sandboxManagerBroken = {
+ removeHold: sinon.stub(),
+ runAsyncCallback: sinon.stub().callsFake(callbackName => {
+ if (callbackName === "preExecution") {
+ throw new Error("mock preExecution failure");
+ }
+ }),
+ };
+ manager.remoteActionSandboxes = {
+ "test-remote-action-broken": sandboxManagerBroken,
+ };
+ manager.localActions = {};
+
+ await manager.preExecution();
+ await manager.runRecipe(recipe);
+ await manager.finalize();
+
+ Assert.deepEqual(
+ sandboxManagerBroken.runAsyncCallback.args,
+ [["preExecution"]],
+ "No async callbacks should be called after preExecution fails",
+ );
+ Assert.deepEqual(
+ sandboxManagerBroken.removeHold.args,
+ [["ActionsManager"]],
+ "sandbox holds should still be removed after a failure",
+ );
+
+ Assert.deepEqual(reportActionStub.args, [
+ ["test-remote-action-broken", Uptake.ACTION_PRE_EXECUTION_ERROR],
+ ]);
+ Assert.deepEqual(reportRecipeStub.args, [[recipe.id, Uptake.RECIPE_ACTION_DISABLED]]);
+ },
+);
+
+// Test life cycle for remote action that fails on a recipe-step
+decorate_task(
+ withStub(Uptake, "reportAction"),
+ withStub(Uptake, "reportRecipe"),
+ async function(reportActionStub, reportRecipeStub) {
+ let manager = new ActionsManager();
+ const recipe = {id: 1, action: "test-remote-action-broken"};
+
+ const sandboxManagerBroken = {
+ removeHold: sinon.stub(),
+ runAsyncCallback: sinon.stub().callsFake(callbackName => {
+ if (callbackName === "action") {
+ throw new Error("mock action failure");
+ }
+ }),
+ };
+ manager.remoteActionSandboxes = {
+ "test-remote-action-broken": sandboxManagerBroken,
+ };
+ manager.localActions = {};
+
+ await manager.preExecution();
+ await manager.runRecipe(recipe);
+ await manager.finalize();
+
+ Assert.deepEqual(
+ sandboxManagerBroken.runAsyncCallback.args,
+ [["preExecution"], ["action", recipe], ["postExecution"]],
+ "postExecution callback should still be called after action callback fails",
+ );
+ Assert.deepEqual(
+ sandboxManagerBroken.removeHold.args,
+ [["ActionsManager"]],
+ "sandbox holds should still be removed after a recipe failure",
+ );
+
+ Assert.deepEqual(reportActionStub.args, [["test-remote-action-broken", Uptake.ACTION_SUCCESS]]);
+ Assert.deepEqual(reportRecipeStub.args, [[recipe.id, Uptake.RECIPE_EXECUTION_ERROR]]);
+ },
+);
+
+// Test life cycle for remote action that fails in post-step
+decorate_task(
+ withStub(Uptake, "reportAction"),
+ withStub(Uptake, "reportRecipe"),
+ async function(reportActionStub, reportRecipeStub) {
+ let manager = new ActionsManager();
+ const recipe = {id: 1, action: "test-remote-action-broken"};
+
+ const sandboxManagerBroken = {
+ removeHold: sinon.stub(),
+ runAsyncCallback: sinon.stub().callsFake(callbackName => {
+ if (callbackName === "postExecution") {
+ throw new Error("mock postExecution failure");
+ }
+ }),
+ };
+ manager.remoteActionSandboxes = {
+ "test-remote-action-broken": sandboxManagerBroken,
+ };
+ manager.localActions = {};
+
+ await manager.preExecution();
+ await manager.runRecipe(recipe);
+ await manager.finalize();
+
+ Assert.deepEqual(
+ sandboxManagerBroken.runAsyncCallback.args,
+ [["preExecution"], ["action", recipe], ["postExecution"]],
+ "All callbacks should be executed",
+ );
+ Assert.deepEqual(
+ sandboxManagerBroken.removeHold.args,
+ [["ActionsManager"]],
+ "sandbox holds should still be removed after a failure",
+ );
+
+ Assert.deepEqual(reportRecipeStub.args, [[recipe.id, Uptake.RECIPE_SUCCESS]]);
+ Assert.deepEqual(reportActionStub.args, [
+ ["test-remote-action-broken", Uptake.ACTION_POST_EXECUTION_ERROR],
+ ]);
+ },
+);
+
+// Test life cycle methods for local actions
+decorate_task(
+ async function(reportActionStub, Stub) {
+ let manager = new ActionsManager();
+ const recipe = {id: 1, action: "test-local-action-used"};
+
+ let actionUsed = {
+ runRecipe: sinon.stub(),
+ finalize: sinon.stub(),
+ };
+ let actionUnused = {
+ runRecipe: sinon.stub(),
+ finalize: sinon.stub(),
+ };
+ manager.localActions = {
+ "test-local-action-used": actionUsed,
+ "test-local-action-unused": actionUnused,
+ };
+ manager.remoteActionSandboxes = {};
+
+ await manager.preExecution();
+ await manager.runRecipe(recipe);
+ await manager.finalize();
+
+ Assert.deepEqual(actionUsed.runRecipe.args, [[recipe]], "used action should be called with the recipe");
+ ok(actionUsed.finalize.calledOnce, "finalize should be called on used action");
+ Assert.deepEqual(actionUnused.runRecipe.args, [], "unused action should not be called with the recipe");
+ ok(actionUnused.finalize.calledOnce, "finalize should be called on the unused action");
+
+ // Uptake telemetry is handled by actions directly, so doesn't
+ // need to be tested for local action handling here.
+ },
+);
+
+// Likewise, error handling is dealt with internal to actions as well,
+// so doesn't need to be tested as a part of ActionsManager.
+
+// Test fetch remote actions
+decorate_task(
+ withStub(NormandyApi, "fetchActions"),
+ withStub(NormandyApi, "fetchImplementation"),
+ withStub(Uptake, "reportAction"),
+ async function(fetchActionsStub, fetchImplementationStub, reportActionStub) {
+ fetchActionsStub.callsFake(async () => [
+ {name: "remoteAction"},
+ {name: "missingImpl"},
+ {name: "migratedAction"},
+ ]);
+ fetchImplementationStub.callsFake(async ({ name }) => {
+ switch (name) {
+ case "remoteAction":
+ return "window.scriptRan = true";
+ case "missingImpl":
+ throw new Error(`Could not fetch implementation for ${name}: test error`);
+ case "migratedAction":
+ return "// this shouldn't be requested";
+ default:
+ throw new Error(`Could not fetch implementation for ${name}: unexpected action`);
+ }
+ });
+
+ const manager = new ActionsManager();
+ manager.localActions = {
+ migratedAction: {finalize: sinon.stub()},
+ };
+
+ await manager.fetchRemoteActions();
+
+ Assert.deepEqual(
+ Object.keys(manager.remoteActionSandboxes),
+ ["remoteAction"],
+ "remote action should have been loaded",
+ );
+
+ Assert.deepEqual(
+ fetchImplementationStub.args,
+ [[{name: "remoteAction"}], [{name: "missingImpl"}]],
+ "all remote actions should be requested",
+ );
+
+ Assert.deepEqual(
+ reportActionStub.args,
+ [["missingImpl", Uptake.ACTION_SERVER_ERROR]],
+ "Missing implementation should be reported via Uptake",
+ );
+
+ ok(
+ await manager.remoteActionSandboxes.remoteAction.evalInSandbox("window.scriptRan"),
+ "Implementations should be run in the sandbox",
+ );
+
+ // clean up sandboxes made by fetchRemoteActions
+ manager.finalize();
+ },
+);
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_BaseAction.js
@@ -0,0 +1,204 @@
+"use strict";
+
+ChromeUtils.import("resource://normandy/actions/BaseAction.jsm", this);
+ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
+
+class NoopAction extends BaseAction {
+ _run(recipe) {
+ // does nothing
+ }
+}
+
+class FailPreExecutionAction extends BaseAction {
+ constructor() {
+ super();
+ this._testRunFlag = false;
+ this._testFinalizeFlag = false;
+ }
+
+ _preExecution() {
+ throw new Error("Test error");
+ }
+
+ _run() {
+ this._testRunFlag = true;
+ }
+
+ _finalize() {
+ this._testFinalizeFlag = true;
+ }
+}
+
+class FailRunAction extends BaseAction {
+ constructor() {
+ super();
+ this._testRunFlag = false;
+ this._testFinalizeFlag = false;
+ }
+
+ _run(recipe) {
+ throw new Error("Test error");
+ }
+
+ _finalize() {
+ this._testFinalizeFlag = true;
+ }
+}
+
+class FailFinalizeAction extends BaseAction {
+ _run(recipe) {
+ // does nothing
+ }
+
+ _finalize() {
+ throw new Error("Test error");
+ }
+}
+
+let _recipeId = 1;
+function recipeFactory(overrides) {
+ let defaults = {
+ id: _recipeId++,
+ arguments: {},
+ };
+ Object.assign(defaults, overrides);
+ return defaults;
+}
+
+// Test that per-recipe uptake telemetry is recorded
+decorate_task(
+ withStub(Uptake, "reportRecipe"),
+ async function(reportRecipeStub) {
+ const action = new NoopAction();
+ const recipe = recipeFactory();
+ await action.runRecipe(recipe);
+ Assert.deepEqual(
+ reportRecipeStub.args,
+ [[recipe.id, Uptake.RECIPE_SUCCESS]],
+ "per-recipe uptake telemetry should be reported",
+ );
+ },
+);
+
+// Finalize causes action telemetry to be recorded
+decorate_task(
+ withStub(Uptake, "reportAction"),
+ async function(reportActionStub) {
+ const action = new NoopAction();
+ await action.finalize();
+ ok(action.finalized, "Action should be marked as finalized");
+ Assert.deepEqual(
+ reportActionStub.args,
+ [[action.name, Uptake.ACTION_SUCCESS]],
+ "action uptake telemetry should be reported",
+ );
+ },
+);
+
+// Recipes can't be run after finalize is called
+decorate_task(
+ withStub(Uptake, "reportRecipe"),
+ async function(reportRecipeStub) {
+ const action = new NoopAction();
+ const recipe1 = recipeFactory();
+ const recipe2 = recipeFactory();
+
+ await action.runRecipe(recipe1);
+ await action.finalize();
+
+ Assert.rejects(
+ action.runRecipe(recipe2),
+ /^Error: Action has already been finalized$/,
+ "running recipes after finalization is an error",
+ );
+
+ Assert.deepEqual(
+ reportRecipeStub.args,
+ [[recipe1.id, Uptake.RECIPE_SUCCESS]],
+ "Only recipes executed prior to finalizer should report uptake telemetry",
+ );
+ },
+);
+
+// Test an action with a failing pre-execution step
+decorate_task(
+ withStub(Uptake, "reportRecipe"),
+ withStub(Uptake, "reportAction"),
+ async function(reportRecipeStub, reportActionStub) {
+ const recipe = recipeFactory();
+ const action = new FailPreExecutionAction();
+ ok(action.failed, "Action should fail during pre-execution fail");
+
+ // Should not throw, even though the action is in a failed state.
+ await action.runRecipe(recipe);
+
+ // Should not throw, even though the action is in a failed state.
+ await action.finalize();
+
+ is(action._testRunFlag, false, "_run should not have been caled");
+ is(action._testFinalizeFlag, false, "_finalize should not have been caled");
+
+ Assert.deepEqual(
+ reportRecipeStub.args,
+ [[recipe.id, Uptake.RECIPE_ACTION_DISABLED]],
+ "Recipe should report recipe status as action disabled",
+ );
+
+ Assert.deepEqual(
+ reportActionStub.args,
+ [[action.name, Uptake.ACTION_PRE_EXECUTION_ERROR]],
+ "Action should report pre execution error",
+ );
+ },
+);
+
+// Test an action with a failing recipe step
+decorate_task(
+ withStub(Uptake, "reportRecipe"),
+ withStub(Uptake, "reportAction"),
+ async function(reportRecipeStub, reportActionStub) {
+ const recipe = recipeFactory();
+ const action = new FailRunAction();
+ await action.runRecipe(recipe);
+ await action.finalize();
+ ok(!action.failed, "Action should not be marked as failed due to a recipe failure");
+
+ ok(action._testFinalizeFlag, "_finalize should have been called");
+
+ Assert.deepEqual(
+ reportRecipeStub.args,
+ [[recipe.id, Uptake.RECIPE_EXECUTION_ERROR]],
+ "Recipe should report recipe execution error",
+ );
+
+ Assert.deepEqual(
+ reportActionStub.args,
+ [[action.name, Uptake.ACTION_SUCCESS]],
+ "Action should report success",
+ );
+ },
+);
+
+// Test an action with a failing finalize step
+decorate_task(
+ withStub(Uptake, "reportRecipe"),
+ withStub(Uptake, "reportAction"),
+ async function(reportRecipeStub, reportActionStub) {
+ const recipe = recipeFactory();
+ const action = new FailFinalizeAction();
+ await action.runRecipe(recipe);
+ await action.finalize();
+
+ Assert.deepEqual(
+ reportRecipeStub.args,
+ [[recipe.id, Uptake.RECIPE_SUCCESS]],
+ "Recipe should report success",
+ );
+
+ Assert.deepEqual(
+ reportActionStub.args,
+ [[action.name, Uptake.ACTION_POST_EXECUTION_ERROR]],
+ "Action should report post execution error",
+ );
+ },
+);
--- a/toolkit/components/normandy/test/browser/browser_RecipeRunner.js
+++ b/toolkit/components/normandy/test/browser/browser_RecipeRunner.js
@@ -1,16 +1,17 @@
"use strict";
ChromeUtils.import("resource://testing-common/TestUtils.jsm", this);
ChromeUtils.import("resource://normandy/lib/RecipeRunner.jsm", this);
ChromeUtils.import("resource://normandy/lib/ClientEnvironment.jsm", this);
ChromeUtils.import("resource://normandy/lib/CleanupManager.jsm", this);
ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this);
ChromeUtils.import("resource://normandy/lib/ActionSandboxManager.jsm", this);
+ChromeUtils.import("resource://normandy/lib/ActionsManager.jsm", this);
ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
add_task(async function getFilterContext() {
const recipe = {id: 17, arguments: {foo: "bar"}, unrelated: false};
const context = RecipeRunner.getFilterContext(recipe);
// Test for expected properties in the filter expression context.
@@ -115,90 +116,56 @@ async function withMockActionSandboxMana
for (const manager of Object.values(managers)) {
manager.removeHold("testing");
await manager.isNuked();
}
}
decorate_task(
- withMockNormandyApi,
withSpy(AddonStudies, "close"),
withStub(Uptake, "reportRunner"),
- withStub(Uptake, "reportAction"),
- withStub(Uptake, "reportRecipe"),
- async function testRun(mockApi, closeSpy, reportRunner, reportAction, reportRecipe) {
- const matchAction = {name: "matchAction"};
- const noMatchAction = {name: "noMatchAction"};
- mockApi.actions = [matchAction, noMatchAction];
-
+ withStub(NormandyApi, "fetchRecipes"),
+ withStub(ActionsManager.prototype, "fetchRemoteActions"),
+ withStub(ActionsManager.prototype, "preExecution"),
+ withStub(ActionsManager.prototype, "runRecipe"),
+ withStub(ActionsManager.prototype, "finalize"),
+ async function testRun(
+ closeSpy,
+ reportRunnerStub,
+ fetchRecipesStub,
+ fetchRemoteActionsStub,
+ preExecutionStub,
+ runRecipeStub,
+ finalizeStub
+ ) {
const matchRecipe = {id: "match", action: "matchAction", filter_expression: "true"};
const noMatchRecipe = {id: "noMatch", action: "noMatchAction", filter_expression: "false"};
const missingRecipe = {id: "missing", action: "missingAction", filter_expression: "true"};
- mockApi.recipes = [matchRecipe, noMatchRecipe, missingRecipe];
-
- await withMockActionSandboxManagers(mockApi.actions, async managers => {
- const matchManager = managers.matchAction;
- const noMatchManager = managers.noMatchAction;
+ fetchRecipesStub.callsFake(async () => [matchRecipe, noMatchRecipe, missingRecipe]);
- await RecipeRunner.run();
-
- // match should be called for preExecution, action, and postExecution
- sinon.assert.calledWith(matchManager.runAsyncCallback, "preExecution");
- sinon.assert.calledWith(matchManager.runAsyncCallback, "action", matchRecipe);
- sinon.assert.calledWith(matchManager.runAsyncCallback, "postExecution");
+ await RecipeRunner.run();
- // noMatch should be called for preExecution and postExecution, and skipped
- // for action since the filter expression does not match.
- sinon.assert.calledWith(noMatchManager.runAsyncCallback, "preExecution");
- sinon.assert.neverCalledWith(noMatchManager.runAsyncCallback, "action", noMatchRecipe);
- sinon.assert.calledWith(noMatchManager.runAsyncCallback, "postExecution");
-
- // missing is never called at all due to no matching action/manager.
+ ok(fetchRemoteActionsStub.calledOnce, "remote actions should be fetched");
+ ok(preExecutionStub.calledOnce, "pre-execution hooks should be run");
+ Assert.deepEqual(
+ runRecipeStub.args,
+ [[matchRecipe], [missingRecipe]],
+ "recipe with matching filters should be executed",
+ );
- // Test uptake reporting
- sinon.assert.calledWith(reportRunner, Uptake.RUNNER_SUCCESS);
- sinon.assert.calledWith(reportAction, "matchAction", Uptake.ACTION_SUCCESS);
- sinon.assert.calledWith(reportAction, "noMatchAction", Uptake.ACTION_SUCCESS);
- sinon.assert.calledWith(reportRecipe, "match", Uptake.RECIPE_SUCCESS);
- sinon.assert.neverCalledWith(reportRecipe, "noMatch", Uptake.RECIPE_SUCCESS);
- sinon.assert.calledWith(reportRecipe, "missing", Uptake.RECIPE_INVALID_ACTION);
- });
+ // Test uptake reporting
+ Assert.deepEqual(
+ reportRunnerStub.args,
+ [[Uptake.RUNNER_SUCCESS]],
+ "RecipeRunner should report uptake telemetry",
+ );
// Ensure storage is closed after the run.
- sinon.assert.calledOnce(closeSpy);
- }
-);
-
-decorate_task(
- withMockNormandyApi,
- async function testRunRecipeError(mockApi) {
- const reportRecipe = sinon.stub(Uptake, "reportRecipe");
-
- const action = {name: "action"};
- mockApi.actions = [action];
-
- const recipe = {id: "recipe", action: "action", filter_expression: "true"};
- mockApi.recipes = [recipe];
-
- await withMockActionSandboxManagers(mockApi.actions, async managers => {
- const manager = managers.action;
- manager.runAsyncCallback.callsFake(async callbackName => {
- if (callbackName === "action") {
- throw new Error("Action execution failure");
- }
- });
-
- await RecipeRunner.run();
-
- // Uptake should report that the recipe threw an exception
- sinon.assert.calledWith(reportRecipe, "recipe", Uptake.RECIPE_EXECUTION_ERROR);
- });
-
- reportRecipe.restore();
+ ok(closeSpy.calledOnce, "Storage should be closed after the run");
}
);
decorate_task(
withMockNormandyApi,
async function testRunFetchFail(mockApi) {
const closeSpy = sinon.spy(AddonStudies, "close");
const reportRunner = sinon.stub(Uptake, "reportRunner");
@@ -232,116 +199,16 @@ decorate_task(
// opened a connection in the first place.
sinon.assert.notCalled(closeSpy);
closeSpy.restore();
reportRunner.restore();
}
);
-decorate_task(
- withMockNormandyApi,
- async function testRunPreExecutionFailure(mockApi) {
- const closeSpy = sinon.spy(AddonStudies, "close");
- const reportAction = sinon.stub(Uptake, "reportAction");
- const reportRecipe = sinon.stub(Uptake, "reportRecipe");
-
- const passAction = {name: "passAction"};
- const failAction = {name: "failAction"};
- mockApi.actions = [passAction, failAction];
-
- const passRecipe = {id: "pass", action: "passAction", filter_expression: "true"};
- const failRecipe = {id: "fail", action: "failAction", filter_expression: "true"};
- mockApi.recipes = [passRecipe, failRecipe];
-
- await withMockActionSandboxManagers(mockApi.actions, async managers => {
- const passManager = managers.passAction;
- const failManager = managers.failAction;
- failManager.runAsyncCallback.returns(Promise.reject(new Error("oh no")));
-
- await RecipeRunner.run();
-
- // pass should be called for preExecution, action, and postExecution
- sinon.assert.calledWith(passManager.runAsyncCallback, "preExecution");
- sinon.assert.calledWith(passManager.runAsyncCallback, "action", passRecipe);
- sinon.assert.calledWith(passManager.runAsyncCallback, "postExecution");
-
- // fail should only be called for preExecution, since it fails during that
- sinon.assert.calledWith(failManager.runAsyncCallback, "preExecution");
- sinon.assert.neverCalledWith(failManager.runAsyncCallback, "action", failRecipe);
- sinon.assert.neverCalledWith(failManager.runAsyncCallback, "postExecution");
-
- sinon.assert.calledWith(reportAction, "passAction", Uptake.ACTION_SUCCESS);
- sinon.assert.calledWith(reportAction, "failAction", Uptake.ACTION_PRE_EXECUTION_ERROR);
- sinon.assert.calledWith(reportRecipe, "fail", Uptake.RECIPE_ACTION_DISABLED);
- });
-
- // Ensure storage is closed after the run, despite the failures.
- sinon.assert.calledOnce(closeSpy);
- closeSpy.restore();
- reportAction.restore();
- reportRecipe.restore();
- }
-);
-
-decorate_task(
- withMockNormandyApi,
- async function testRunPostExecutionFailure(mockApi) {
- const reportAction = sinon.stub(Uptake, "reportAction");
-
- const failAction = {name: "failAction"};
- mockApi.actions = [failAction];
-
- const failRecipe = {action: "failAction", filter_expression: "true"};
- mockApi.recipes = [failRecipe];
-
- await withMockActionSandboxManagers(mockApi.actions, async managers => {
- const failManager = managers.failAction;
- failManager.runAsyncCallback.callsFake(async callbackName => {
- if (callbackName === "postExecution") {
- throw new Error("postExecution failure");
- }
- });
-
- await RecipeRunner.run();
-
- // fail should be called for every stage
- sinon.assert.calledWith(failManager.runAsyncCallback, "preExecution");
- sinon.assert.calledWith(failManager.runAsyncCallback, "action", failRecipe);
- sinon.assert.calledWith(failManager.runAsyncCallback, "postExecution");
-
- // Uptake should report a post-execution error
- sinon.assert.calledWith(reportAction, "failAction", Uptake.ACTION_POST_EXECUTION_ERROR);
- });
-
- reportAction.restore();
- }
-);
-
-decorate_task(
- withMockNormandyApi,
- async function testLoadActionSandboxManagers(mockApi) {
- mockApi.actions = [
- {name: "normalAction"},
- {name: "missingImpl"},
- ];
- mockApi.implementations.normalAction = "window.scriptRan = true";
-
- const managers = await RecipeRunner.loadActionSandboxManagers();
- ok("normalAction" in managers, "Actions with implementations have managers");
- ok(!("missingImpl" in managers), "Actions without implementations are skipped");
-
- const normalManager = managers.normalAction;
- ok(
- await normalManager.evalInSandbox("window.scriptRan"),
- "Implementations are run in the sandbox",
- );
- }
-);
-
// test init() in dev mode
decorate_task(
withPrefEnv({
set: [
["datareporting.healthreport.uploadEnabled", true], // telemetry enabled
["app.normandy.dev_mode", true],
["app.normandy.first_run", false],
],
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_action_ConsoleLog.js
@@ -0,0 +1,45 @@
+"use strict";
+
+ChromeUtils.import("resource://normandy/actions/ConsoleLog.jsm", this);
+ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
+
+// Test that logging works
+add_task(async function logging_works() {
+ const action = new ConsoleLog();
+ const infoStub = sinon.stub(action.log, "info");
+ try {
+ const recipe = {id: 1, arguments: {message: "Hello, world!"}};
+ await action.runRecipe(recipe);
+ Assert.deepEqual(infoStub.args, ["Hello, world!"], "the message should be logged");
+ } finally {
+ infoStub.restore();
+ }
+});
+
+
+// test that argument validation works
+decorate_task(
+ withStub(Uptake, "reportRecipe"),
+ async function arguments_are_validated(reportRecipeStub) {
+ const action = new ConsoleLog();
+ const infoStub = sinon.stub(action.log, "info");
+
+ try {
+ // message is required
+ let recipe = {id: 1, arguments: {}};
+ await action.runRecipe(recipe);
+ Assert.deepEqual(infoStub.args, [], "no message should be logged");
+ Assert.deepEqual(reportRecipeStub.args, [[recipe.id, Uptake.RECIPE_EXECUTION_ERROR]]);
+
+ reportRecipeStub.reset();
+
+ // message must be a string
+ recipe = {id: 1, arguments: {message: 1}};
+ await action.runRecipe(recipe);
+ Assert.deepEqual(infoStub.args, [], "no message should be logged");
+ Assert.deepEqual(reportRecipeStub.args, [[recipe.id, Uptake.RECIPE_EXECUTION_ERROR]]);
+ } finally {
+ infoStub.restore();
+ }
+ },
+);
--- a/toolkit/components/normandy/test/browser/head.js
+++ b/toolkit/components/normandy/test/browser/head.js
@@ -124,17 +124,17 @@ this.withMockNormandyApi = function(test
// Use callsFake instead of resolves so that the current values in mockApi are used.
mockApi.fetchActions = sinon.stub(NormandyApi, "fetchActions").callsFake(async () => mockApi.actions);
mockApi.fetchRecipes = sinon.stub(NormandyApi, "fetchRecipes").callsFake(async () => mockApi.recipes);
mockApi.fetchImplementation = sinon.stub(NormandyApi, "fetchImplementation").callsFake(
async action => {
const impl = mockApi.implementations[action.name];
if (!impl) {
- throw new Error("Missing");
+ throw new Error(`Missing implementation for ${action.name}`);
}
return impl;
}
);
try {
await testFunction(mockApi, ...args);
} finally {