Skip to content

Commit af5c196

Browse files
committed
Add support for asynchronous tests with pending state.
1 parent f1665e5 commit af5c196

File tree

3 files changed

+200
-45
lines changed

3 files changed

+200
-45
lines changed

lib/sandbox/execute.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const _ = require('lodash'),
2020
EXECUTION_ASSERTION_EVENT = 'execution.assertion',
2121
EXECUTION_ASSERTION_EVENT_BASE = 'execution.assertion.',
2222

23+
TESTS_RESOLVED_EVENT = 'tests.resolved',
24+
2325
executeContext = require('./execute-context');
2426

2527
module.exports = function (bridge, glob) {
@@ -34,7 +36,10 @@ module.exports = function (bridge, glob) {
3436
// For caching required information provided during
3537
// initialization which will be used during execution
3638
let initializationOptions = {},
37-
initializeExecution;
39+
initializeExecution,
40+
41+
// Tests state in the context of the current execution
42+
testsState = {};
3843

3944
/**
4045
* @param {Object} options
@@ -129,15 +134,24 @@ module.exports = function (bridge, glob) {
129134
* @param {Boolean} assertions[].async -
130135
* @param {Boolean} assertions[].passed -
131136
* @param {Boolean} assertions[].skipped -
137+
* @param {Boolean} resolved -
132138
*/
133-
dispatchAssertions = function (assertions) {
139+
dispatchAssertions = function (assertions, resolved) {
134140
// Legacy `test` API accumulates all the assertions and dispatches at once
135141
// whereas, `pm.test` dispatch on every single assertion.
136142
// For compatibility, dispatch the single assertion as an array.
137143
!Array.isArray(assertions) && (assertions = [assertions]);
138144

139145
bridge.dispatch(assertionEventName, options.cursor, assertions);
140146
bridge.dispatch(EXECUTION_ASSERTION_EVENT, options.cursor, assertions);
147+
148+
const dispatchAllTestsResolved =
149+
resolved &&
150+
Object.values(testsState).every((v) => { return v.resolved; });
151+
152+
if (dispatchAllTestsResolved) {
153+
bridge.dispatch(TESTS_RESOLVED_EVENT);
154+
}
141155
};
142156

143157
let waiting,
@@ -214,7 +228,7 @@ module.exports = function (bridge, glob) {
214228
var eventId = timers.setEvent(callback);
215229

216230
bridge.dispatch(executionRequestEventName, options.cursor, id, eventId, request);
217-
}, dispatchAssertions, new PostmanCookieStore(id, bridge, timers), {
231+
}, testsState, dispatchAssertions, new PostmanCookieStore(id, bridge, timers), {
218232
disabledAPIs: initializationOptions.disabledAPIs
219233
})
220234
),

lib/sandbox/pmapi-setup-runner.js

Lines changed: 180 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
1+
/* eslint-disable one-var */
12
/**
23
* @fileOverview
34
*
45
* This module externally sets up the test runner on pm api. Essentially, it does not know the insides of pm-api and
56
* does the job completely from outside with minimal external dependency
67
*/
7-
const FUNCTION = 'function';
8+
const _ = require('lodash'),
9+
FUNCTION = 'function',
10+
uuid = require('../vendor/uuid'),
11+
12+
OPTIONS = {
13+
When: 'when',
14+
RunCount: 'runCount',
15+
RunUntil: 'runUntil'
16+
},
17+
OPTION_TYPE = {
18+
[OPTIONS.When]: 'function',
19+
[OPTIONS.RunCount]: 'number',
20+
[OPTIONS.RunUntil]: 'number'
21+
};
822

923
/**
1024
* @module {PMAPI~setupTestRunner}
1125
* @private
1226
*
1327
* @param {PMAPI} pm - an instance of PM API that it needs
14-
* @param {Function} onAssertionComplete - is the trigger function that is called every time a test is executed and it
28+
* @param {Object} testsState - State of all the tests for the current execution
29+
* @param {Function} onAssertion - is the trigger function that is called every time a test is encountered and it
1530
* receives the AssertionInfo object outlining details of the assertion
1631
*/
17-
module.exports = function (pm, onAssertionComplete) {
32+
module.exports = function (pm, testsState, onAssertion) {
1833
var assertionIndex = 0,
1934

2035
/**
@@ -25,24 +40,73 @@ module.exports = function (pm, onAssertionComplete) {
2540
*
2641
* @param {String} name -
2742
* @param {Boolean} skipped -
43+
* @param {String} [id] -
2844
*
2945
* @returns {PMAPI~AssertionInfo}
3046
*/
31-
getAssertionObject = function (name, skipped) {
47+
getAssertionObject = function (name, skipped, id) {
48+
if (!id) {
49+
id = uuid();
50+
}
51+
3252
/**
3353
* @typeDef {AssertionInfo}
3454
* @private
3555
*/
3656
return {
57+
id: id,
3758
name: String(name),
3859
async: false,
3960
skipped: Boolean(skipped),
4061
passed: true,
62+
pending: !skipped,
4163
error: null,
4264
index: assertionIndex++ // increment the assertion counter (do it before asserting)
4365
};
4466
},
4567

68+
generateTestId = function (eventName, testName, assertFn, options) {
69+
return [
70+
eventName,
71+
testName,
72+
assertFn ? assertFn.toString() : '',
73+
JSON.stringify(options)
74+
].join('');
75+
},
76+
77+
getDefaultTestState = function (options) {
78+
return {
79+
...(options ? _.pick(options, _.values(OPTIONS)) : {}),
80+
id: uuid(),
81+
timer: null,
82+
currRunCount: 0,
83+
resolved: false
84+
};
85+
},
86+
87+
isOptionConfigured = function (options, optionName) {
88+
return _.has(options, optionName) && typeof options[optionName] === OPTION_TYPE[optionName];
89+
},
90+
91+
92+
validateOptions = function (options) {
93+
if (!options || typeof options !== 'object') {
94+
throw new Error('Invalid test option: options is not an object');
95+
}
96+
97+
const supportedOptions = _.values(OPTIONS);
98+
99+
Object.keys(options).forEach((optionName) => {
100+
if (!supportedOptions.includes(optionName)) {
101+
throw new Error(`Invalid test option: ${optionName} is not a supported option`);
102+
}
103+
104+
if (typeof options[optionName] !== OPTION_TYPE[optionName]) {
105+
throw new Error(`Invalid test options: ${optionName} is not a ${OPTION_TYPE[optionName]}`);
106+
}
107+
});
108+
},
109+
46110
/**
47111
* Simple function to mark an assertion as failed
48112
*
@@ -57,59 +121,135 @@ module.exports = function (pm, onAssertionComplete) {
57121
markAssertionAsFailure = function (assertionData, err) {
58122
assertionData.error = err;
59123
assertionData.passed = false;
124+
},
125+
126+
processAssertion = function (_testId, assertionData, options) {
127+
const testState = testsState[_testId];
128+
129+
if (testState.resolved) {
130+
return;
131+
}
132+
133+
const shouldResolve =
134+
assertionData.error || // TODO: Make conditions (test status) to mark a test resolved, configurable.
135+
assertionData.skipped ||
136+
_.isEmpty(options) ||
137+
!testState ||
138+
isOptionConfigured(options, OPTIONS.RunCount) && testState.runCount === testState.currRunCount ||
139+
isOptionConfigured(options, OPTIONS.RunUntil) && !testState.timer;
140+
141+
142+
testState.resolved = shouldResolve;
143+
assertionData.pending = !shouldResolve;
144+
145+
onAssertion(assertionData, shouldResolve);
146+
},
147+
148+
updateTestState = function (_testId, assertionData, options) {
149+
const testState = testsState[_testId],
150+
startTimer = !testState.timer && isOptionConfigured(options, OPTIONS.RunUntil);
151+
152+
testState.currRunCount++;
153+
154+
if (startTimer) {
155+
testState.timer = setTimeout(() => {
156+
testState.timer = null;
157+
processAssertion(_testId, assertionData, options);
158+
}, testState.runUntil);
159+
}
60160
};
61161

62162
/**
63163
* @param {String} name -
164+
* @param {Object} [options] -
64165
* @param {Function} assert -
65166
* @chainable
66167
*/
67-
pm.test = function (name, assert) {
68-
var assertionData = getAssertionObject(name, false);
168+
pm.test = function (name, options, assert) {
169+
if (typeof options === FUNCTION) {
170+
assert = options;
171+
options = {};
172+
}
173+
174+
if (_.isNil(options) || typeof options !== 'object') {
175+
options = {};
176+
}
177+
178+
// TODO: Make generateTestId safe i.e handle invalid `options` as well
179+
const _testId = generateTestId(pm.info.eventName, name, assert, options);
180+
181+
if (!testsState[_testId]) {
182+
testsState[_testId] = getDefaultTestState(options);
183+
}
184+
185+
const testState = testsState[_testId],
186+
testId = testState.testId,
187+
assertionData = getAssertionObject(name, false, testId);
69188

70189
// if there is no assertion function, we simply move on
71190
if (typeof assert !== FUNCTION) {
72-
onAssertionComplete(assertionData);
191+
// Sending `options` as empty to force resolve the test
192+
processAssertion(_testId, assertionData, {});
73193

74194
return pm;
75195
}
76196

77-
// if a callback function was sent, then we know that the test is asynchronous
78-
if (assert.length) {
79-
try {
80-
assertionData.async = true; // flag that this was an async test (would be useful later)
81-
82-
// we execute assertion, but pass it a completion function, which, in turn, raises the completion
83-
// event. we do not need to worry about timers here since we are assuming that some timer within the
84-
// sandbox had actually been the source of async calls and would take care of this
85-
assert(function (err) {
86-
// at first we double check that no synchronous error has happened from the catch block below
87-
if (assertionData.error && assertionData.passed === false) {
88-
return;
89-
}
90-
91-
// user triggered a failure of the assertion, so we mark it the same
92-
if (err) {
93-
markAssertionAsFailure(assertionData, err);
94-
}
95-
96-
onAssertionComplete(assertionData);
97-
});
197+
try { validateOptions(options); }
198+
catch (e) {
199+
markAssertionAsFailure(assertionData, e);
200+
processAssertion(_testId, assertionData, options);
201+
202+
return pm;
203+
}
204+
205+
const shouldRun =
206+
!testState.resolved &&
207+
(isOptionConfigured(options, OPTIONS.When) ? Boolean(options.when()) : true) &&
208+
(isOptionConfigured(options, OPTIONS.RunCount) ? testState.currRunCount < options.runCount : true);
209+
210+
if (shouldRun) {
211+
updateTestState(_testId, assertionData, options);
212+
213+
// if a callback function was sent, then we know that the test is asynchronous
214+
if (assert.length) {
215+
try {
216+
assertionData.async = true; // flag that this was an async test (would be useful later)
217+
218+
// we execute assertion, but pass it a completion function, which, in turn, raises the completion
219+
// event. we do not need to worry about timers here since we are assuming that some timer within the
220+
// sandbox had actually been the source of async calls and would take care of this
221+
assert(function (err) {
222+
// at first we double check that no synchronous error has happened from the catch block below
223+
if (assertionData.error && assertionData.passed === false) {
224+
return;
225+
}
226+
227+
// user triggered a failure of the assertion, so we mark it the same
228+
if (err) {
229+
markAssertionAsFailure(assertionData, err);
230+
}
231+
232+
processAssertion(_testId, assertionData, options);
233+
});
234+
}
235+
// in case a synchronous error occurs in the the async assertion, we still bail out.
236+
catch (e) {
237+
markAssertionAsFailure(assertionData, e);
238+
processAssertion(_testId, assertionData, options);
239+
}
98240
}
99-
// in case a synchronous error occurs in the the async assertion, we still bail out.
100-
catch (e) {
101-
markAssertionAsFailure(assertionData, e);
102-
onAssertionComplete(assertionData);
241+
// if the assertion function does not expect a callback, we synchronously execute the same
242+
else {
243+
try { assert(); }
244+
catch (e) {
245+
markAssertionAsFailure(assertionData, e);
246+
}
247+
248+
processAssertion(_testId, assertionData, options);
103249
}
104250
}
105-
// if the assertion function does not expect a callback, we synchronously execute the same
106251
else {
107-
try { assert(); }
108-
catch (e) {
109-
markAssertionAsFailure(assertionData, e);
110-
}
111-
112-
onAssertionComplete(assertionData);
252+
processAssertion(_testId, assertionData, options);
113253
}
114254

115255
return pm; // make it chainable
@@ -121,7 +261,7 @@ module.exports = function (pm, onAssertionComplete) {
121261
*/
122262
pm.test.skip = function (name) {
123263
// trigger the assertion events with skips
124-
onAssertionComplete(getAssertionObject(name, true));
264+
processAssertion(name, getAssertionObject(name, true), {});
125265

126266
return pm; // chainable
127267
};

lib/sandbox/pmapi.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,13 @@ const _ = require('lodash'),
4444
*
4545
* @param {Execution} execution -
4646
* @param {Function} onRequest -
47+
* @param {Object} testsState -
4748
* @param {Function} onAssertion -
4849
* @param {Object} cookieStore -
4950
* @param {Object} [options] -
5051
* @param {Array.<String>} [options.disabledAPIs] -
5152
*/
52-
function Postman (execution, onRequest, onAssertion, cookieStore, options = {}) {
53+
function Postman (execution, onRequest, testsState, onAssertion, cookieStore, options = {}) {
5354
// @todo - ensure runtime passes data in a scope format
5455
let iterationData = new VariableScope();
5556

@@ -254,7 +255,7 @@ function Postman (execution, onRequest, onAssertion, cookieStore, options = {})
254255
}, options.disabledAPIs);
255256

256257
// extend pm api with test runner abilities
257-
setupTestRunner(this, onAssertion);
258+
setupTestRunner(this, testsState, onAssertion);
258259

259260
// add response assertions
260261
if (this.response) {

0 commit comments

Comments
 (0)