runner/extensions/event.command.js

var _ = require('lodash'),
    uuid = require('uuid'),
    async = require('async'),

    util = require('../util'),
    sdk = require('postman-collection'),
    sandbox = require('postman-sandbox'),
    serialisedError = require('serialised-error'),

    createItemContext = require('../create-item-context'),

    ASSERTION_FAILURE = 'AssertionFailure',
    SAFE_CONTEXT_VARIABLES = ['_variables', 'environment', 'globals', 'collectionVariables', 'cookies', 'data',
        'request', 'response'],

    EXECUTION_REQUEST_EVENT_BASE = 'execution.request.',
    EXECUTION_RESPONSE_EVENT_BASE = 'execution.response.',
    EXECUTION_ASSERTION_EVENT_BASE = 'execution.assertion.',
    EXECUTION_ERROR_EVENT_BASE = 'execution.error.',

    FILE = 'file',

    REQUEST_BODY_MODE_FILE = 'file',
    REQUEST_BODY_MODE_FORMDATA = 'formdata',

    postProcessContext, // fn
    sanitizeFiles; // fn

postProcessContext = function (execution, failures) { // function determines whether the event needs to abort
    var error;

    if (failures && failures.length) {
        error = new Error(failures.join(', '));
        error.name = ASSERTION_FAILURE;
    }
    return error ? serialisedError(error, true) : undefined;
};

/**
 * Removes files in Request body if any.
 *
 * @private
 *
 * @param {Request~definition} request Request JSON representation to be sanitized
 * @param {Function} callback function invoked with error, request and sanitisedFiles.
 * sanitisedFiles is the list of files removed from request.
 *
 * @note this function mutates the request
 */
sanitizeFiles = function (request, callback) {
    if (!request) {
        return callback(new Error('Could not complete pm.sendRequest. Request is empty.'));
    }

    var sanitisedFiles = [];

    // do nothing if request body is empty
    if (!request.body) {
        // send request as such
        return callback(null, request, sanitisedFiles);
    }

    // in case of request body mode is file, we strip it out
    if (request.body.mode === REQUEST_BODY_MODE_FILE) {
        sanitisedFiles.push(_.get(request, 'body.file.src'));
        request.body = null; // mutate the request for body
    }

    // if body is form-data then we deep dive into the data items and remove the entries that have file data
    else if (request.body.mode === REQUEST_BODY_MODE_FORMDATA) {
        _.remove(request.body.formdata, function (param) {
            // blank param and non-file param is removed
            if (!param || param.type !== FILE) { return false; }

            // at this point the param needs to be removed
            sanitisedFiles.push(param.src);
            return true;
        });
    }

    return callback(null, request, sanitisedFiles);
};

/**
 * Script execution extension of the runner.
 * This module exposes processors for executing scripts before and after requests. Essentially, the processors are
 * itself not aware of other processors and simply allow running of a script and then queue a procesor as defined in
 * payload.
 *
 * Adds options
 * - stopOnScriptError:Boolean [false]
 * - host:Object [undefined]
 */
module.exports = {
    init: function (done) {
        var run = this;

        // if this run object already has a host, we do not need to create one.
        if (run.host) {
            return done();
        }

        // @todo - remove this when chrome app and electron host creation is offloaded to runner
        // @todo - can this be removed now in runtime v4?
        if (run.options.host && run.options.host.external === true) {
            run.host = run.options.host.instance;
            return done();
        }

        sandbox.createContext(_.merge({
            timeout: _(run.options.timeout).pick(['script', 'global']).values().min()
            // debug: true
        }, run.options.host), function (err, context) {
            if (err) { return done(err); }
            // store the host in run object for future use and move on
            run.host = context;

            context.on('console', function () {
                run.triggers.console.apply(run.triggers, arguments);
            });

            context.on('error', function () {
                run.triggers.error.apply(run.triggers, arguments);
            });

            context.on('execution.error', function () {
                run.triggers.exception.apply(run.triggers, arguments);
            });

            context.on('execution.assertion', function () {
                run.triggers.assertion.apply(run.triggers, arguments);
            });

            done();
        });
    },

    /**
     * This lists the name of the events that the script processors are likely to trigger
     *
     * @type {Array}
     */
    triggers: ['beforeScript', 'script', 'assertion', 'exception', 'console'],

    process: {
        /**
         * This processors job is to do the following:
         * - trigger event by its name
         * - execute all scripts that the event listens to and return execution results
         *
         * @param {Object} payload
         * @param {String} payload.name
         * @param {Item} payload.item
         * @param {Object} [payload.context]
         * @param {Cursor} [payload.coords]
         * @param {Number} [payload.scriptTimeout] - The millisecond timeout for the current running script.
         * @param {Array.<String>} [payload.trackContext]
         * @param {Boolean} [payload.stopOnScriptError] - if set to true, then a synchronous error encountered during
         * execution of a script will stop executing any further scripts
         * @param {Boolean} [payload.abortOnFailure]
         * @param {Boolean} [payload.stopOnFailure]
         * @param {Function} next
         *
         * @note - in order to raise trigger for the entire event, ensure your extension has registered the triggers
         */
        event: function (payload, next) {
            var item = payload.item,
                eventName = payload.name,
                cursor = payload.coords,
                // the payload can have a list of variables to track from the context post execution, ensure that
                // those are accurately set
                track = _.isArray(payload.trackContext) && _.isObject(payload.context) &&
                    // ensure that only those variables that are defined in the context are synced
                    payload.trackContext.filter(function (variable) {
                        return _.isObject(payload.context[variable]);
                    }),
                stopOnScriptError = (_.has(payload, 'stopOnScriptError') ? payload.stopOnScriptError :
                    this.options.stopOnScriptError),
                abortOnError = (_.has(payload, 'abortOnError') ? payload.abortOnError : this.options.abortOnError),

                // @todo: find a better home for this option processing
                abortOnFailure = payload.abortOnFailure,
                stopOnFailure = payload.stopOnFailure,

                events;

            // @todo: find a better place to code this so that event is not aware of such options
            if (abortOnFailure) {
                abortOnError = true;
            }

            // validate the payload
            if (!eventName) {
                return next(new Error('runner.extension~events: event payload is missing the event name.'));
            }
            if (!item) {
                return next(new Error('runner.extension~events: event payload is missing the triggered item.'));
            }

            // get the list of events to be executed
            // includes events in parent as well
            events = item.events.listeners(eventName);

            // call the "before" event trigger by its event name.
            // at this point, the one who queued this event, must ensure that the trigger for it is defined in its
            // 'trigger' interface
            this.triggers[_.camelCase('before-' + eventName)](null, cursor, events, item);

            // with all the event listeners in place, we now iterate on them and execute its scripts. post execution,
            // we accumulate the results in order to be passed on to the event callback trigger.
            async.mapSeries(events, function (event, next) {
                // in case the event has no script we bail out early
                if (!event.script) {
                    return next(null, {event: event});
                }

                // get access to the script from the event.
                var script = event.script,
                    executionId = uuid(),
                    assertionFailed = [],

                    // create copy of cursor so we don't leak script ids outside `event.command`
                    // and across scripts
                    scriptCursor = _.clone(cursor);

                // store the execution id in script
                script._lastExecutionId = executionId; // please don't use it anywhere else!

                // if we can find an id on script or event we add them to the cursor
                // so logs and errors can be traced back to the script they came from
                event.id && (scriptCursor.eventId = event.id);
                event.script.id && (scriptCursor.scriptId = event.script.id);

                // trigger the "beforeScript" callback
                this.triggers.beforeScript(null, scriptCursor, script, event, item);

                // add event listener to trap all assertion events, but only if needed. to avoid needlessly accumulate
                // stuff in memory.
                (abortOnFailure || stopOnFailure) &&
                    this.host.on(EXECUTION_ASSERTION_EVENT_BASE + executionId, function (scriptCursor, assertions) {
                        _.forEach(assertions, function (assertion) {
                            assertion && !assertion.passed && assertionFailed.push(assertion.name);
                        });
                    });

                this.host.on(EXECUTION_REQUEST_EVENT_BASE + executionId,
                    function (scriptCursor, id, requestId, request) {
                        // remove files in request body if any
                        sanitizeFiles(request, function (err, request, sanitisedFiles) {
                            if (err) {
                                return this.host.dispatch(EXECUTION_RESPONSE_EVENT_BASE + id, requestId, err);
                            }

                            var nextPayload;

                            // if request is sanitized send a warning
                            if (!_.isEmpty(sanitisedFiles)) {
                                this.triggers.console(scriptCursor, 'warn',
                                    'uploading files from scripts is not allowed');
                            }

                            nextPayload = {
                                item: new sdk.Item({request: request}),
                                coords: scriptCursor,
                                // @todo - get script type from the sandbox
                                source: 'script',
                                // abortOnError makes sure request command bubbles errors
                                // so we can pass it on to the callback
                                abortOnError: true
                            };

                            // create context for executing this request
                            nextPayload.context = createItemContext(nextPayload);

                            this.immediate('httprequest', nextPayload).done(function (result) {
                                this.host.dispatch(
                                    EXECUTION_RESPONSE_EVENT_BASE + id,
                                    requestId,
                                    null,
                                    result && result.response
                                );
                            }).catch(function (err) {
                                this.host.dispatch(EXECUTION_RESPONSE_EVENT_BASE + id, requestId, err);
                            });
                        }.bind(this));
                    }.bind(this));

                // finally execute the script
                this.host.execute(event, {
                    id: executionId,
                    // debug: true,
                    timeout: payload.scriptTimeout, // @todo: Expose this as a property in Collection SDK's Script
                    cursor: scriptCursor,
                    context: _.pick(payload.context, SAFE_CONTEXT_VARIABLES),

                    // legacy options
                    legacy: {
                        _itemId: item.id,
                        _itemName: item.name
                    }
                }, function (err, result) {
                    this.host.removeAllListeners(EXECUTION_REQUEST_EVENT_BASE + executionId);
                    this.host.removeAllListeners(EXECUTION_ASSERTION_EVENT_BASE + executionId);
                    this.host.removeAllListeners(EXECUTION_ERROR_EVENT_BASE + executionId);

                    // electron IPC does not bubble errors to the browser process, so we serialize it here.
                    err && (err = serialisedError(err, true));

                    // if it is defined that certain variables are to be synced back to result, we do the same
                    track && result && track.forEach(function (variable) {
                        if (!(_.isObject(result[variable]) && payload.context[variable])) { return; }

                        var contextVariable = payload.context[variable],
                            mutations = result[variable].mutations;

                        // bail out if there are no mutations
                        if (!mutations) {
                            return;
                        }

                        // ensure that variable scope is treated accordingly
                        if (_.isFunction(contextVariable.applyMutation)) {
                            mutations = new sdk.MutationTracker(result[variable].mutations);

                            mutations.applyOn(contextVariable);
                        }

                        // @todo: unify the non variable scope flows and consume diff always
                        // and drop sending the full variable scope from sandbox
                        else {
                            util.syncObject(contextVariable, result[variable]);
                        }
                    });

                    // Get the failures. If there was an error running the script itself, that takes precedence
                    if (!err && (abortOnFailure || stopOnFailure)) {
                        err = postProcessContext(result, assertionFailed); // also use async assertions
                    }

                    // Ensure that we have SDK instances, not serialized plain objects.
                    // @todo - should this be handled by the sandbox?
                    result && result._variables && (result._variables = new sdk.VariableScope(result._variables));
                    result && result.environment && (result.environment = new sdk.VariableScope(result.environment));
                    result && result.globals && (result.globals = new sdk.VariableScope(result.globals));
                    result && result.collectionVariables &&
                        (result.collectionVariables = new sdk.VariableScope(result.collectionVariables));
                    result && result.request && (result.request = new sdk.Request(result.request));
                    result && result.response && (result.response = new sdk.Response(result.response));

                    // persist the pm.variables for the next script
                    payload.context._variables = new sdk.VariableScope(result._variables);
                    // persist the pm.variables for the next request
                    this.state._variables = new sdk.VariableScope(result._variables);

                    // now that this script is done executing, we trigger the event and move to the next script
                    this.triggers.script(err || null, scriptCursor, result, script, event, item);

                    // move to next script and pass on the results for accumulation
                    next(((stopOnScriptError || abortOnError || stopOnFailure) && err) ? err : null, _.assign({
                        event: event,
                        script: script,
                        result: result
                    }, err && {error: err})); // we use assign here to avoid needless error property
                }.bind(this));

            }.bind(this), function (err, results) {
                // trigger the event completion callback
                this.triggers[eventName](null, cursor, results, item);
                next((abortOnError && err) ? err : null, results, err);
            }.bind(this));
        }
    }
};