"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(); }, );