feat: Created a mini nodeJS server with NewMan for testing without PostMan GUI.

This will mimic a run in a CD/CI environment or docker container.
This commit is contained in:
Simon Priet
2021-09-08 14:01:19 +02:00
parent 5fbd7c88fa
commit e69a613a37
5610 changed files with 740417 additions and 3 deletions

View File

@@ -0,0 +1,105 @@
var _ = require('lodash'),
util = require('../util'),
backpack = require('../../backpack');
module.exports = {
/**
* All the events that this extension triggers
* @type {Array}
*/
triggers: ['pause', 'resume', 'abort'],
prototype: /** @lends Run.prototype */ {
/**
* Pause a run
*
* @param {Function} callback
*/
pause: function (callback) {
callback = backpack.ensure(callback, this);
if (this.paused) { return callback && callback(new Error('run: already paused')); }
// schedule the pause command as an interrupt and flag that the run is pausing
this.paused = true;
this.interrupt('pause', null, callback);
},
/**
* Resume a paused a run
*
* @param {Function} callback
*/
resume: function (callback) {
callback = backpack.ensure(callback, this);
if (!this.paused) { return callback && callback(new Error('run: not paused')); }
// set flag that it is no longer paused and fire the stored callback for the command when it was paused
this.paused = false;
setTimeout(function () {
this.__resume();
delete this.__resume;
this.triggers.resume(null, this.state.cursor.current());
}.bind(this), 0);
callback && callback();
},
/**
* Aborts a run
*
* @param {boolean} [summarise=true]
* @param {function} callback
*/
abort: function (summarise, callback) {
if (_.isFunction(summarise) && !callback) {
callback = summarise;
summarise = true;
}
this.interrupt('abort', {
summarise: summarise
}, callback);
_.isFunction(this.__resume) && this.resume();
}
},
process: /** @lends Run.commands */ {
pause: function (userback, payload, next) {
// trigger the secondary callbacks
this.triggers.pause(null, this.state.cursor.current());
// tuck away the command completion callback in the run object so that it can be used during resume
this.__resume = next;
// execute the userback sent as part of the command and do so in a try block to ensure it does not hamper
// the process tick
var error = util.safeCall(userback, this);
// if there is an error executing the userback, then and only then raise the error (which stops the run)
if (error) {
return next(error);
}
},
/**
* @param {Function} userback
* @param {Object} payload
* @param {Boolean} payload.summarise
* @param {Function} next
*/
abort: function (userback, payload, next) {
// clear instruction pool and as such there will be nothing next to execute
this.pool.clear();
this.triggers.abort(null, this.state.cursor.current());
// execute the userback sent as part of the command and do so in a try block to ensure it does not hamper
// the process tick
backpack.ensure(userback, this) && userback();
next(null);
}
}
};

View File

@@ -0,0 +1,62 @@
var _ = require('lodash');
module.exports = {
init: function (done) {
done();
},
triggers: ['waitStateChange'],
prototype: {
/**
* @param {Function} fn - function to execute
* @param {Object} options
* @param {String} options.source
* @param {Number} options.time
* @param {Object} options.cursor
* @param {Function} next
* @private
*/
queueDelay: function (fn, options, next) {
var time = _.isFinite(options.time) ? parseInt(options.time, 10) : 0;
// if the time is a valid and finite time, we queue the delay command
if (time > 0) {
this.queue('delay', {
cursor: options.cursor,
source: options.source,
time: time
}).done(fn);
}
// otherwise, we do not delay and simply execute the function that was supposed to be called post delay
else {
fn();
}
next();
}
},
process: {
/**
* @param {Object} payload
* @param {Number} payload.time
* @param {Object} payload.cursor
* @param {String} payload.source
* @param {Function} next
*/
delay: function (payload, next) {
var cursor = payload.cursor || this.state.cursor.current();
this.waiting = true; // set flag
// trigger the waiting stae change event
this.triggers.waitStateChange(null, cursor, true, payload.time, payload.source);
setTimeout((function () {
this.waiting = false; // unset flag
this.triggers.waitStateChange(null, cursor, false, payload.time, payload.source);
next();
}).bind(this), payload.time || 0);
}
}
};

View File

@@ -0,0 +1,530 @@
var _ = require('lodash'),
uuid = require('uuid'),
async = require('async'),
util = require('../util'),
sdk = require('postman-collection'),
sandbox = require('postman-sandbox'),
serialisedError = require('serialised-error'),
ToughCookie = require('tough-cookie').Cookie,
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.',
EXECUTION_COOKIES_EVENT_BASE = 'execution.cookies.',
COOKIES_EVENT_STORE_ACTION = 'store',
COOKIE_STORE_PUT_METHOD = 'putCookie',
COOKIE_STORE_UPDATE_METHOD = 'updateCookie',
FILE = 'file',
REQUEST_BODY_MODE_FILE = 'file',
REQUEST_BODY_MODE_FORMDATA = 'formdata',
getCookieDomain, // fn
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
* @todo remove files path from request.certificate
*/
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) {
// eslint-disable-next-line lodash/prefer-immutable-method
_.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);
};
/**
* Fetch domain name from CookieStore event arguments.
*
* @private
* @param {String} fnName - CookieStore method name
* @param {Array} args - CookieStore method arguments
* @returns {String|Undefined} - Domain name
*/
getCookieDomain = function (fnName, args) {
if (!(fnName && args)) {
return;
}
var domain;
switch (fnName) {
case 'findCookie':
case 'findCookies':
case 'removeCookie':
case 'removeCookies':
domain = args[0];
break;
case 'putCookie':
case 'updateCookie':
domain = args[0] && args[0].domain;
break;
default:
return;
}
return domain;
};
/**
* 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, {excludeDisabled: true});
// 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 = [],
asyncScriptError,
// 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);
});
});
// To store error event, but only if needed. Because error in callback of host.execute()
// don't show execution errors for async scripts
(abortOnError || stopOnScriptError) &&
// only store first async error in case of multiple errors
this.host.once(EXECUTION_ERROR_EVENT_BASE + executionId, function (scriptCursor, error) {
if (error && !(error instanceof Error)) {
error = new Error(error.message || error);
}
asyncScriptError = error;
// @todo: Figure out a way to abort the script execution here as soon as we get an error.
// We can send `execution.abort.` event to sandbox for this, but currently it silently
// terminates the script execution without triggering the callback.
});
this.host.on(EXECUTION_COOKIES_EVENT_BASE + executionId,
function (eventId, action, fnName, args) {
// only store action is supported, might need to support
// more cookie actions in next 2 years ¯\_(ツ)_/¯
if (action !== COOKIES_EVENT_STORE_ACTION) { return; }
var self = this,
dispatchEvent = EXECUTION_COOKIES_EVENT_BASE + executionId,
cookieJar = _.get(self, 'requester.options.cookieJar'),
cookieStore = cookieJar && cookieJar.store,
cookieDomain;
if (!cookieStore) {
return self.host.dispatch(dispatchEvent, eventId, 'CookieStore: no store found');
}
if (typeof cookieStore[fnName] !== 'function') {
return self.host.dispatch(dispatchEvent, eventId,
`CookieStore: invalid method name '${fnName}'`);
}
!Array.isArray(args) && (args = []);
// set expected args length to make sure callback is always called
args.length = cookieStore[fnName].length - 1;
// there's no way cookie store can identify the difference
// between regular and programmatic access. So, for now
// we check for programmatic access using the cookieJar
// helper method and emit the default empty value for that
// method.
// @note we don't emit access denied error here because
// that might blocks users use-case while accessing
// cookies for a sub-domain.
cookieDomain = getCookieDomain(fnName, args);
if (cookieJar && typeof cookieJar.allowProgrammaticAccess === 'function' &&
!cookieJar.allowProgrammaticAccess(cookieDomain)) {
return self.host.dispatch(dispatchEvent, eventId,
`CookieStore: programmatic access to "${cookieDomain}" is denied`);
}
// serialize cookie object
if (fnName === COOKIE_STORE_PUT_METHOD && args[0]) {
args[0] = ToughCookie.fromJSON(args[0]);
}
if (fnName === COOKIE_STORE_UPDATE_METHOD && args[0] && args[1]) {
args[0] = ToughCookie.fromJSON(args[0]);
args[1] = ToughCookie.fromJSON(args[1]);
}
// add store method's callback argument
args.push(function (err, res) {
// serialize error message
if (err && err instanceof Error) {
err = err.message || String(err);
}
self.host.dispatch(dispatchEvent, eventId, err, res);
});
try {
cookieStore[fnName].apply(cookieStore, args);
}
catch (error) {
self.host.dispatch(dispatchEvent, eventId,
`runtime~CookieStore: error executing "${fnName}"`);
}
}.bind(this));
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,
// @todo get cookies from result.history or pass PostmanHistory
// instance once it is fully supported
result && {cookies: result.cookies}
);
}).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),
serializeLogs: _.get(this, 'options.script.serializeLogs'),
// 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_RESPONSE_EVENT_BASE + executionId);
this.host.removeAllListeners(EXECUTION_COOKIES_EVENT_BASE + executionId);
this.host.removeAllListeners(EXECUTION_ERROR_EVENT_BASE + executionId);
// Handle async errors as well.
// If there was an error running the script itself, that takes precedence
if (!err && asyncScriptError) {
err = asyncScriptError;
}
// 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));
// @note Since postman-sandbox@3.5.2, response object is not included in the execution result.
// Refer: https://github.com/postmanlabs/postman-sandbox/pull/512
// Adding back here to avoid breaking change in `script` callback.
// @todo revisit script callback args in runtime v8.
result && payload.context && payload.context.response &&
(result.response = new sdk.Response(payload.context.response));
// persist the pm.variables for the next script
result && result._variables &&
(payload.context._variables = new sdk.VariableScope(result._variables));
// persist the pm.variables for the next request
result && result._variables && (this.state._variables = new sdk.VariableScope(result._variables));
// persist the mutated request in payload context,
// @note this will be used for the next prerequest script or
// upcoming commands(request, httprequest).
result && result.request && (payload.context.request = result.request);
// 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));
}
}
};

View File

@@ -0,0 +1,211 @@
var _ = require('lodash'),
async = require('async'),
uuid = require('uuid'),
// These are functions which a request passes through _before_ being sent. They take care of stuff such as
// variable resolution, loading of files, etc.
prehelpers = require('../request-helpers-presend'),
// Similarly, these run after the request, and have the power to dictate whether a request should be re-queued
posthelpers = require('../request-helpers-postsend'),
ReplayController = require('../replay-controller'),
RequesterPool = require('../../requester').RequesterPool,
RESPONSE_START_EVENT_BASE = 'response.start.',
RESPONSE_END_EVENT_BASE = 'response.end.';
module.exports = {
init: function (done) {
// Request timeouts are applied by the requester, so add them to requester options (if any).
// create a requester pool
this.requester = new RequesterPool(this.options, done);
},
// the http trigger is actually directly triggered by the requester
// todo - figure out whether we should trigger it from here rather than the requester.
triggers: ['beforeRequest', 'request', 'responseStart', 'io'],
process: {
/**
* @param {Object} payload
* @param {Item} payload.item
* @param {Object} payload.data
* @param {Object} payload.context
* @param {VariableScope} payload.globals
* @param {VariableScope} payload.environment
* @param {Cursor} payload.coords
* @param {Boolean} payload.abortOnError
* @param {String} payload.source
* @param {Function} next
*
* @todo validate payload
*/
httprequest: function (payload, next) {
var abortOnError = _.has(payload, 'abortOnError') ? payload.abortOnError : this.options.abortOnError,
self = this,
context;
context = payload.context;
// generates a unique id for each http request
// a collection request can have multiple http requests
_.set(context, 'coords.httpRequestId', payload.httpRequestId || uuid());
// Run the helper functions
async.applyEachSeries(prehelpers, context, self, function (err) {
var xhr,
aborted,
item = context.item,
beforeRequest,
afterRequest,
safeNext;
// finish up current command
safeNext = function (error, finalPayload) {
// the error is passed twice to allow control between aborting the error vs just
// bubbling it up
return next((error && abortOnError) ? error : null, finalPayload, error);
};
// Helper function which calls the beforeRequest trigger ()
beforeRequest = function (err) {
self.triggers.beforeRequest(err, context.coords, item.request, payload.item, {
httpRequestId: context.coords && context.coords.httpRequestId,
abort: function () {
!aborted && xhr && xhr.abort();
aborted = true;
}
});
};
// Helper function to call the afterRequest trigger.
afterRequest = function (err, response, request, cookies, history) {
self.triggers.request(err, context.coords, response, request, payload.item, cookies, history);
};
// Ensure that this is called.
beforeRequest(null);
if (err) {
// Since we encountered an error before even attempting to send the request, we bubble it up
// here.
afterRequest(err, undefined, item.request);
return safeNext(
err,
{request: item.request, coords: context.coords, item: context.originalItem}
);
}
if (aborted) {
return next(new Error('runtime: request aborted'));
}
self.requester.create({
type: 'http',
source: payload.source,
cursor: context.coords
}, function (err, requester) {
if (err) { return next(err); } // this should never happen
var requestId = uuid(),
replayOptions;
// eslint-disable-next-line max-len
requester.on(RESPONSE_START_EVENT_BASE + requestId, function (err, response, request, cookies, history) {
// we could have also added the response to the set of responses in the cloned item,
// but then, we would have to iterate over all of them, which seems unnecessary
context.response = response;
// run the post request helpers, which need to use the response, assigned above
async.applyEachSeries(posthelpers, context, self, function (error, options) {
if (error) {
return;
}
// find the first helper that requested a replay
replayOptions = _.find(options, {replay: true});
// bail out if we know that request will be replayed.
if (replayOptions) {
return;
}
// bail out if its a pm.sendRequest
// @todo find a better way of identifying scripts
// @note don't use source='script'. Script requests
// can trigger `*.auth` source requests as well.
if (context.coords && context.coords.scriptId) {
return;
}
// trigger responseStart only for collection request.
// if there are replays, this will be triggered for the last request in the replay chain.
self.triggers.responseStart(err, context.coords, response, request, payload.item, cookies,
history);
});
});
requester.on(RESPONSE_END_EVENT_BASE + requestId, self.triggers.io.bind(self.triggers));
// eslint-disable-next-line max-len
xhr = requester.request(requestId, item.request, context.protocolProfileBehavior, function (err, res, req, cookies, history) {
err = err || null;
var nextPayload = {
response: res,
request: req,
item: context.originalItem,
cookies: cookies,
coords: context.coords,
history: history
},
replayController;
// trigger the request event.
// @note - we give the _original_ item in this trigger, so someone can do reference
// checking. Not sure if we should do that or not, but that's how it is.
// Don't break it.
afterRequest(err, res, req, cookies, history);
// Dispose off the requester, we don't need it anymore.
requester.dispose();
// do not process replays if there was an error
if (err) {
return safeNext(err, nextPayload);
}
// request replay logic
if (replayOptions) {
// prepare for replay
replayController = new ReplayController(context.replayState, self);
// replay controller invokes callback no. 1 when replaying the request
// invokes callback no. 2 when replay count has exceeded maximum limit
// @note: errors in replayed requests are passed to callback no. 1
return replayController.requestReplay(context,
context.item,
{source: replayOptions.helper},
// new payload with response from replay is sent to `next`
function (err, payloadFromReplay) { safeNext(err, payloadFromReplay); },
// replay was stopped, move on with older payload
function (err) {
// warn users that maximum retries have exceeded
// but don't bubble up the error with the request
self.triggers.console(context.coords, 'warn', (err.message || err));
safeNext(null, nextPayload);
}
);
}
// finish up for any other request
return safeNext(err, nextPayload);
});
});
});
}
}
};

View File

@@ -0,0 +1,277 @@
var _ = require('lodash'),
uuid = require('uuid'),
Response = require('postman-collection').Response,
visualizer = require('../../visualizer'),
/**
* List of request properties which can be mutated via pre-request
*
* @private
* @const
* @type {String[]}
*/
ALLOWED_REQUEST_MUTATIONS = ['url', 'method', 'headers', 'body'],
extractVisualizerData,
getResponseJSON;
/**
* Returns visualizer data from the latest execution result.
*
* @param {Array} prereqExecutions - pre-script executions results
* @param {Array} testExecutions - test-script executions results
* @returns {Object|undefined} - visualizer data
*/
extractVisualizerData = function (prereqExecutions, testExecutions) {
var visualizerData,
i;
if (_.isArray(testExecutions)) {
// loop through the test executions in reverse order to return data from latest execution
for (i = testExecutions.length - 1; i >= 0; i--) {
visualizerData = _.get(testExecutions[i], 'result.return.visualizer');
if (visualizerData) {
return visualizerData;
}
}
}
if (_.isArray(prereqExecutions)) {
// extract visualizer data from pre-request script results if it is not found earlier
for (i = prereqExecutions.length - 1; i >= 0; i--) {
visualizerData = _.get(prereqExecutions[i], 'result.return.visualizer');
if (visualizerData) {
return visualizerData;
}
}
}
};
/**
* Convert response into a JSON serializable object.
* The stream property is converted to base64 string for performance reasons.
*
* @param {Object} response - SDK Response instance
* @returns {Object}
*/
getResponseJSON = function (response) {
if (!Response.isResponse(response)) {
return;
}
return {
id: response.id,
code: response.code,
status: response.status,
header: response.headers && response.headers.toJSON(),
stream: response.stream && {
type: 'Base64',
data: response.stream.toString('base64')
},
responseTime: response.responseTime
};
};
/**
* Add options
* stopOnError:Boolean
* @type {Object}
*/
module.exports = {
init: function (done) {
// @todo - code item global timeout and delay here
done();
},
triggers: ['beforeItem', 'item', 'beforePrerequest', 'prerequest', 'beforeTest', 'test'],
process: {
/**
* @param {Function=} callback
* @param {Object} payload
* @param {Function} next
* @todo validate payload
*/
item: function (callback, payload, next) {
// adjust for polymorphic instructions
if (!next && _.isFunction(payload) && !_.isFunction(callback)) {
next = payload;
payload = callback;
callback = null;
}
var item = payload.item,
originalRequest = item.request.clone(),
coords = payload.coords,
data = _.isObject(payload.data) ? payload.data : {},
environment = _.isObject(payload.environment) ? payload.environment : {},
globals = _.isObject(payload.globals) ? payload.globals : {},
collectionVariables = _.isObject(payload.collectionVariables) ? payload.collectionVariables : {},
_variables = _.isObject(payload._variables) ? payload._variables : {},
stopOnError = _.has(payload, 'stopOnError') ? payload.stopOnError : this.options.stopOnError,
// @todo: this is mostly coded in event extension and we are
// still not sure whether that is the right place for it to be.
abortOnFailure = this.options.abortOnFailure,
stopOnFailure = this.options.stopOnFailure,
delay = _.get(this.options, 'delay.item'),
ctxTemplate;
// validate minimum parameters required for the command to work
if (!(item && coords)) {
return next(new Error('runtime: item execution is missing required parameters'));
}
// store a common uuid in the coords
coords.ref = uuid.v4();
// here we code to queue prerequest script, then make a request and then execute test script
this.triggers.beforeItem(null, coords, item);
this.queueDelay(function () {
// create the context object for scripts to run
ctxTemplate = {
collectionVariables: collectionVariables,
_variables: _variables,
globals: globals,
environment: environment,
data: data,
request: item.request
};
// @todo make it less nested by coding Instruction.thenQueue
this.queue('event', {
name: 'prerequest',
item: item,
coords: coords,
context: ctxTemplate,
trackContext: ['globals', 'environment', 'collectionVariables'],
stopOnScriptError: stopOnError,
stopOnFailure: stopOnFailure
}).done(function (prereqExecutions, prereqExecutionError) {
// if stop on error is marked and script executions had an error,
// do not proceed with more commands, instead we bail out
if ((stopOnError || stopOnFailure) && prereqExecutionError) {
this.triggers.item(null, coords, item); // @todo - should this trigger receive error?
return callback && callback.call(this, prereqExecutionError, {
prerequest: prereqExecutions
});
}
// update allowed request mutation properties with the mutated context
// @note from this point forward, make sure this mutated
// request instance is used for upcoming commands.
ALLOWED_REQUEST_MUTATIONS.forEach(function (property) {
if (_.has(ctxTemplate, ['request', property])) {
item.request[property] = ctxTemplate.request[property];
}
// update property's parent reference
if (item.request[property] && typeof item.request[property].setParent === 'function') {
item.request[property].setParent(item.request);
}
});
this.queue('request', {
item: item,
globals: ctxTemplate.globals,
environment: ctxTemplate.environment,
collectionVariables: ctxTemplate.collectionVariables,
_variables: ctxTemplate._variables,
data: ctxTemplate.data,
coords: coords,
source: 'collection'
}).done(function (result, requestError) {
!result && (result = {});
var request = result.request,
response = result.response,
cookies = result.cookies;
if ((stopOnError || stopOnFailure) && requestError) {
this.triggers.item(null, coords, item); // @todo - should this trigger receive error?
return callback && callback.call(this, requestError, {
request: request
});
}
// also the test object requires the updated request object (since auth helpers may modify it)
request && (ctxTemplate.request = request);
// @note convert response instance to plain object.
// we want to avoid calling Response.toJSON() which triggers toJSON on Response.stream buffer.
// Because that increases the size of stringified object by 3 times.
// Also, that increases the total number of tokens (buffer.data) whereas Buffer.toString
// generates a single string that is easier to stringify and sent over the UVM bridge.
response && (ctxTemplate.response = getResponseJSON(response));
// set cookies for this transaction
cookies && (ctxTemplate.cookies = cookies);
// the context template also has a test object to store assertions
ctxTemplate.tests = {}; // @todo remove
this.queue('event', {
name: 'test',
item: item,
coords: coords,
context: ctxTemplate,
trackContext: ['tests', 'globals', 'environment', 'collectionVariables'],
stopOnScriptError: stopOnError,
abortOnFailure: abortOnFailure,
stopOnFailure: stopOnFailure
}).done(function (testExecutions, testExecutionError) {
var visualizerData = extractVisualizerData(prereqExecutions, testExecutions),
visualizerResult;
if (visualizerData) {
visualizer.processTemplate(visualizerData.template,
visualizerData.data,
visualizerData.options,
function (err, processedTemplate) {
visualizerResult = {
// bubble up the errors while processing template through visualizer result
error: err,
// add processed template and data to visualizer result
processedTemplate: processedTemplate,
data: visualizerData.data
};
// trigger an event saying that item has been processed
this.triggers.item(null, coords, item, visualizerResult);
}.bind(this));
}
else {
// trigger an event saying that item has been processed
// @todo - should this trigger receive error?
this.triggers.item(null, coords, item, null);
}
// reset mutated request with original request instance
// @note request mutations are not persisted across iterations
item.request = originalRequest;
callback && callback.call(this, ((stopOnError || stopOnFailure) && testExecutionError) ?
testExecutionError : null, {
prerequest: prereqExecutions,
request: request,
response: response,
test: testExecutions
});
});
});
});
}.bind(this), {
time: delay,
source: 'item',
cursor: coords
}, next);
}
}
};

View File

@@ -0,0 +1,100 @@
var _ = require('lodash'),
sdk = require('postman-collection'),
createItemContext = require('../create-item-context'),
/**
* Resolve variables in item and auth in context.
*
* @param {ItemContext} context
* @param {Item} [context.item]
* @param {RequestAuth} [context.auth]
* @param {Object} payload
* @param {VariableScope} payload._variables
* @param {Object} payload.data
* @param {VariableScope} payload.environment
* @param {VariableScope} payload.collectionVariables
* @param {VariableScope} payload.globals
*/
resolveVariables = function (context, payload) {
if (!(context.item && context.item.request)) { return; }
// @todo - resolve variables in a more graceful way
var variableDefinitions = [
// extract the variable list from variable scopes
// @note: this is the order of precedence for variable resolution - don't change it
payload._variables.values,
payload.data,
payload.environment.values,
payload.collectionVariables.values,
payload.globals.values
],
urlString = context.item.request.url.toString(),
item,
auth;
// @todo - no need to sync variables when SDK starts supporting resolution from scope directly
// @todo - avoid resolving the entire item as this unnecessarily resolves URL
item = context.item = new sdk.Item(context.item.toObjectResolved(null,
variableDefinitions, {ignoreOwnVariables: true}));
auth = context.auth;
// resolve variables in URL string
if (urlString) {
// @note this adds support resolving nested variables as URL parser doesn't support them well.
urlString = sdk.Property.replaceSubstitutions(urlString, variableDefinitions);
// Re-parse the URL from the resolved string
item.request.url = new sdk.Url(urlString);
}
// resolve variables in auth
auth && (context.auth = new sdk.RequestAuth(auth.toObjectResolved(null,
variableDefinitions, {ignoreOwnVariables: true})));
};
module.exports = {
init: function (done) {
done();
},
triggers: ['response'],
process: {
request: function (payload, next) {
var abortOnError = _.has(payload, 'abortOnError') ? payload.abortOnError : this.options.abortOnError,
// helper function to trigger `response` callback anc complete the command
complete = function (err, nextPayload) {
// nextPayload will be empty for unhandled errors
// trigger `response` callback
// nextPayload.response will be empty for error flows
// the `item` argument is resolved and mutated here
nextPayload && this.triggers.response(err, nextPayload.coords, nextPayload.response,
nextPayload.request, nextPayload.item, nextPayload.cookies, nextPayload.history);
// the error is passed twice to allow control between aborting the error vs just
// bubbling it up
return next(err && abortOnError ? err : null, nextPayload, err);
}.bind(this),
context = createItemContext(payload);
// resolve variables in item and auth
resolveVariables(context, payload);
// add context for use, after resolution
payload.context = context;
// we do not queue `httprequest` instruction here,
// queueing will unblock the item command to prepare for the next `event` instruction
// at this moment request is not fulfilled, and we want to block it
this.immediate('httprequest', payload)
.done(function (nextPayload, err) {
// change signature to error first
complete(err, nextPayload);
})
.catch(complete);
}
}
};

View File

@@ -0,0 +1,239 @@
var _ = require('lodash'),
Cursor = require('../cursor'),
VariableScope = require('postman-collection').VariableScope,
prepareLookupHash,
extractSNR,
getIterationData;
/**
* Returns a hash of IDs and Names of items in an array
*
* @param {Array} items
* @returns {Object}
*/
prepareLookupHash = function (items) {
var hash = {
ids: {},
names: {},
obj: {}
};
_.forEach(items, function (item, index) {
if (item) {
item.id && (hash.ids[item.id] = index);
item.name && (hash.names[item.name] = index);
}
});
return hash;
};
extractSNR = function (executions, previous) {
var snr = previous || {};
_.isArray(executions) && executions.forEach(function (execution) {
_.has(_.get(execution, 'result.return'), 'nextRequest') && (
(snr.defined = true),
(snr.value = execution.result.return.nextRequest)
);
});
return snr;
};
/**
* Returns the data for the given iteration
*
* @function getIterationData
* @param {Array} data - The data array containing all iterations' data
* @param {Number} iteration - The iteration to get data for
* @return {Any} - The data for the iteration
*/
getIterationData = function (data, iteration) {
// if iteration has a corresponding data element use that
if (iteration < data.length) {
return data[iteration];
}
// otherwise use the last data element
return data[data.length - 1];
};
/**
* Adds options
* disableSNR:Boolean
*
* @type {Object}
*/
module.exports = {
init: function (done) {
var state = this.state;
// ensure that the environment, globals and collectionVariables are in VariableScope instance format
state.environment = VariableScope.isVariableScope(state.environment) ? state.environment :
new VariableScope(state.environment);
state.globals = VariableScope.isVariableScope(state.globals) ? state.globals :
new VariableScope(state.globals);
state.collectionVariables = VariableScope.isVariableScope(state.collectionVariables) ?
state.collectionVariables : new VariableScope(state.collectionVariables);
state._variables = new VariableScope();
// ensure that the items and iteration data set is in place
!_.isArray(state.items) && (state.items = []);
!_.isArray(state.data) && (state.data = []);
!_.isObject(state.data[0]) && (state.data[0] = {});
// if the location in state is already normalised then go ahead and queue iteration, else normalise the
// location
state.cursor = Cursor.box(state.cursor, { // we pass bounds to ensure there is no stale state
cycles: this.options.iterationCount,
length: state.items.length
});
this.waterfall = state.cursor; // copy the location object to instance for quick access
// queue the iteration command on start
this.queue('waterfall', {
coords: this.waterfall.current(),
static: true,
start: true
});
// clear the variable that is supposed to store item name and id lookup hash for easy setNextRequest
this.snrHash = null; // we populate it in the first SNR call
done();
},
triggers: ['beforeIteration', 'iteration'],
process: {
/**
* This processor simply queues scripts and requests in a linear chain.
*
* @param {Object} payload
* @param {Object} payload.coords
* @param {Boolean} [payload.static=false]
* @param {Function} next
*/
waterfall: function (payload, next) {
// we procure the coordinates that we have to pick item and data from. the data is
var coords = payload.static ? payload.coords : this.waterfall.whatnext(payload.coords),
item = this.state.items[coords.position],
delay;
// if there is nothing to process, we bail out from here, even before we enter the iteration cycle
if (coords.empty) {
return next();
}
if (payload.stopRunNow) {
this.triggers.iteration(null, payload.coords);
return next();
}
// if it is a beginning of a run, we need to raise events for iteration start
if (payload.start) {
this.triggers.beforeIteration(null, coords);
}
// if this is a new iteration, we close the previous one and start new
if (coords.cr) {
// getting the iteration delay here ensures that delay is only called between two iterations
delay = _.get(this.options, 'delay.iteration', 0);
this.triggers.iteration(null, payload.coords);
this.triggers.beforeIteration(null, coords);
}
// if this is end of waterfall, it is an end of iteration and also end of run
if (coords.eof) {
this.triggers.iteration(null, coords);
return next();
}
this.queueDelay(function () {
this.queue('item', {
item: item,
coords: coords,
data: getIterationData(this.state.data, coords.iteration),
environment: this.state.environment,
globals: this.state.globals,
collectionVariables: this.state.collectionVariables,
_variables: this.state._variables
}, function (executionError, executions) {
var snr = {},
nextCoords,
seekingToStart,
stopRunNow,
stopOnFailure = this.options.stopOnFailure;
if (!executionError) {
// extract set next request
snr = extractSNR(executions.prerequest);
snr = extractSNR(executions.test, snr);
}
if (!this.options.disableSNR && snr.defined) {
// prepare the snr lookup hash if it is not already provided
// @todo - figure out a way to reset this post run complete
!this.snrHash && (this.snrHash = prepareLookupHash(this.state.items));
// if it is null, we do not proceed further and move on
// see if a request is found in the hash and then reset the coords position to the lookup
// value.
(snr.value !== null) && (snr.position = // eslint-disable-next-line no-nested-ternary
this.snrHash[_.has(this.snrHash.ids, snr.value) ? 'ids' :
(_.has(this.snrHash.names, snr.value) ? 'names' : 'obj')][snr.value]);
snr.valid = _.isNumber(snr.position);
}
nextCoords = _.clone(coords);
if (snr.valid) {
// if the position was detected, we set the position to the one previous to the desired location
// this ensures that the next call to .whatnext() will return the desired position.
nextCoords.position = snr.position - 1;
}
else {
// if snr was requested, but not valid, we stop this iteration.
// stopping an iteration is equivalent to seeking the last position of the current
// iteration, so that the next call to .whatnext() will automatically move to the next
// iteration.
(snr.defined || executionError) && (nextCoords.position = nextCoords.length - 1);
// If we need to stop on a run, we set the stop flag to true.
(stopOnFailure && executionError) && (stopRunNow = true);
}
// @todo - do this in unhacky way
if (nextCoords.position === -1) {
nextCoords.position = 0;
seekingToStart = true;
}
this.waterfall.seek(nextCoords.position, nextCoords.iteration, function (err, chngd, coords) {
// this condition should never arise, so better throw error when this happens
if (err) {
throw err;
}
this.queue('waterfall', {
coords: coords,
static: seekingToStart,
stopRunNow: stopRunNow
});
}, this);
});
}.bind(this), {
time: delay,
source: 'iteration',
cursor: coords
}, next);
}
}
};