diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index 986d4854b..4dac07b59 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -1,3 +1,13 @@ +unreleased: + fixed bugs: + - >- + Fixed `pm.execution.runRequest` hanging in runs with + `customParallelIterations`: the nested run now runs once, sequentially, + the `runRequest` chain is kept per script execution instead of in run + state shared by all partitions, nested requests use the calling + partition's cookie jar, and stopping a partition aborts its in-flight + nested runs + 7.56.1: date: 2026-08-10 fixed bugs: diff --git a/lib/runner/extensions/event.command.js b/lib/runner/extensions/event.command.js index 8d677d908..b51600c98 100644 --- a/lib/runner/extensions/event.command.js +++ b/lib/runner/extensions/event.command.js @@ -341,7 +341,13 @@ module.exports = { assertionFailed = [], asyncScriptError, - isNestedRequest = this.state.nestedRequest !== undefined, + // The pm.execution.runRequest chain this script belongs to. A nested run is handed its + // chain; a top-level script starts its own on its first runRequest call (see + // lib/runner/nested-request.js). Kept per execution, not in run state, so concurrent + // partitions of a run never see each other's chain. + nestedRequestScope = { nestedRequest: this.state.nestedRequest }, + + isNestedRequest = nestedRequestScope.nestedRequest !== undefined, rootItemId = isNestedRequest && this.state.nestedRequest.rootItem ? this.state.nestedRequest.rootItem.id : item.id, @@ -480,7 +486,7 @@ module.exports = { if (hasVaultAccess === undefined) { try { // eslint-disable-next-line require-atomic-updates - hasVaultAccess = Boolean(this.state.nestedRequest?.hasVaultAccess || + hasVaultAccess = Boolean(nestedRequestScope.nestedRequest?.hasVaultAccess || await vaultSecrets?._?.allowScriptAccess(rootItemId)); } catch (_) { @@ -726,7 +732,9 @@ module.exports = { }.bind(this)); this.host.on(EXECUTION_RUN_REQUEST_EVENT_BASE + executionId, - runNestedRequest({ executionId, isExecutionSkipped, vaultSecrets, item }).bind(this)); + runNestedRequest({ + executionId, isExecutionSkipped, vaultSecrets, item, nestedRequestScope + }).bind(this)); this.host.on(EXECUTION_SKIP_REQUEST_EVENT_BASE + executionId, function () { skippedExecutions.add(executionId); @@ -845,7 +853,7 @@ module.exports = { // Prevent mutations from being carry-forwarded to subsequent events vaultSecrets.disableTracking(); - if (hasVaultAccess || this.state.nestedRequest?.hasVaultAccess) { + if (hasVaultAccess || nestedRequestScope.nestedRequest?.hasVaultAccess) { result.vaultSecrets = vaultSecrets; } } @@ -882,7 +890,7 @@ module.exports = { if (!isNestedRequest) { // Reset for next invocation if there are subsequent executions of items in a single // runner via `iterationCount` or multiple `items` passed to a collection. - delete this.state.nestedRequest; + delete nestedRequestScope.nestedRequest; } // move to next script and pass on the results for accumulation diff --git a/lib/runner/nested-request.js b/lib/runner/nested-request.js index 1271f3fb3..738d55144 100644 --- a/lib/runner/nested-request.js +++ b/lib/runner/nested-request.js @@ -6,7 +6,30 @@ const _ = require('lodash'), SYNCABLE_CONTEXT_VARIABLE_SCOPES = ['_variables', 'collectionVariables', 'environment', 'globals'], EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE = 'execution.run_collection_request_response.'; -function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item }) { +/** + * Starts the pm.execution.runRequest chain of a top-level script. The chain is kept per script execution and handed + * down to every nested run, so concurrent partitions of a run each get their own. + * + * @param {Run} run - the run of the top-level script + * @param {Object} cursor - the top-level script's cursor + * @param {Item} item - the top-level item + * @returns {Object} + */ +function createNestedRequestChain (run, cursor, item) { + const partition = run.areIterationsParallelized && + _.get(run, ['partitionManager', 'partitions', cursor.partitionIndex]); + + return { + isNestedRequest: true, + rootCursor: cursor, + rootItem: item, + callStack: [], + // nested runs of this chain, aborted when the partition is stopped + nestedRuns: partition ? partition.nestedRuns : undefined + }; +} + +function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item, nestedRequestScope }) { // Note: Don't forget to bind this function to the runner to make sure `this` can give you the right options & state return function (cursor, eventId, requestId, requestToRunId, runRequestOptions = {}, runContext = {}) { const self = this, @@ -14,19 +37,26 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item runRequestRespEvent = EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE + eventId, maxInvokableNestedRequests = _.get(self, 'options.maxInvokableNestedRequests'), itemId = item.id, + + // Prepare nested request object for passing down to child request + // Have to keep object reference common so any changes made by nested executions is bubbled back to + // parent exec. A top-level script starts the chain on its first call. + nestedRequest = nestedRequestScope.nestedRequest || + (nestedRequestScope.nestedRequest = createNestedRequestChain(self, cursor, item)), + nestedRuns = nestedRequest.nestedRuns, currentItemState = { isPoppedFromCallStack: false }, popCurrentItemFromCallStack = () => { if (currentItemState.isPoppedFromCallStack) { return; } currentItemState.isPoppedFromCallStack = true; - if (!self.state.nestedRequest.callStack.length) { return; } + if (!nestedRequest.callStack.length) { return; } - const { callStack } = self.state.nestedRequest, + const { callStack } = nestedRequest, itemIndex = callStack.lastIndexOf(itemId); if (itemIndex !== -1) { - self.state.nestedRequest.callStack.splice(itemIndex, 1); + nestedRequest.callStack.splice(itemIndex, 1); } }; @@ -41,26 +71,22 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item requestId, error); } - // Prepare nested request object for passing down to child request - // Have to keep object reference common so any changes made by nested executions is bubbled back to parent exec - self.state.nestedRequest = _.defaults(self.state.nestedRequest || {}, { - isNestedRequest: true, - rootCursor: cursor, - rootItem: item, - callStack: [] - }); + nestedRequest.callStack.push(itemId); - self.state.nestedRequest.callStack.push(itemId); + // The partition that started this chain was stopped, don't start anything new for it + if (nestedRuns && nestedRuns.aborted) { + return dispatchErrorToListener(new Error('pm.execution.runRequest: the iteration was stopped.')); + } // No more than maxInvokableNestedRequests nested depth - if (self.state.nestedRequest.callStack.length > maxInvokableNestedRequests) { + if (nestedRequest.callStack.length > maxInvokableNestedRequests) { return dispatchErrorToListener(new Error('Max pm.execution.runRequest depth of ' + maxInvokableNestedRequests + ' has been reached.')); } - let rootRequestEventsList = self.state.nestedRequest.rootItem.events?.all?.() || [], + let rootRequestEventsList = nestedRequest.rootItem.events?.all?.() || [], nestedRequestMeta = null, - context = { rootItemId: self.state.nestedRequest.rootItem.id }; + context = { rootItemId: nestedRequest.rootItem.id }; for (const event of rootRequestEventsList) { if (event.script?.requests?.[requestToRunId]) { @@ -113,7 +139,16 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item parentIterationRow = self.state._dataIterator ? self.state._dataRow : getIterationData(_.isEmpty(self.state.data) ? [{}] : self.state.data, - self.state.nestedRequest.rootCursor.iteration); + nestedRequest.rootCursor.iteration), + + // A partitioned run hands its nested requests the calling partition's cookies: its own jar when + // per-partition jars are on, else the run's shared jar. An explicit jar also turns off per-partition + // jars for the nested run. Deeper nested runs inherit it through `requester`. Other runs keep their + // requester options as they are. + requester = self.areIterationsParallelized ? { + ..._.omit(self.options.requester, 'perPartitionCookieJar'), + cookieJar: self.getCookieJarFor(cursor) || _.get(self, 'requester.options.cookieJar') + } : self.options.requester; // Merge parent collection's variables with this collection's variables // Why the separate statement? Because we want the referenced request's collection's variables to @@ -155,6 +190,11 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item localVariables: localVariables, vaultSecrets: clonedVaultSecrets, stopOnError: true, + nestedRequest: nestedRequest, + // The nested request runs once, sequentially, for the script that invoked it + customParallelIterations: false, + maxConcurrency: 1, + requester: requester, host: { // Reuse current run's sandbox host across nested executions external: true, @@ -173,6 +213,15 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item .dispatch(EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE + eventId, requestId, err); } + + // the partition may have been stopped while the request was being resolved + if (nestedRuns && nestedRuns.aborted) { + return dispatchErrorToListener(new Error('pm.execution.runRequest: the iteration was ' + + 'stopped.')); + } + + nestedRuns && nestedRuns.runs.add(run); + run.start({ script (_err, _cursor, result) { // This is to sync changes to pm.variables, pm.environment & pm.globals @@ -206,8 +255,8 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item // For nested requests, the request event should bubble up to the parent. // This event is merely used for notifying the consumer that a nested request was sent // similar to pm.sendRequest. So the consumer can work this into logs & any other logic. - if (typeof self.triggers.request === 'function' && self.state && self.state.nestedRequest) { - self.triggers.request(err, self.state.nestedRequest.rootCursor, ...rest); + if (typeof self.triggers.request === 'function' && nestedRequestScope.nestedRequest) { + self.triggers.request(err, nestedRequest.rootCursor, ...rest); } }, exception (_, err) { @@ -239,6 +288,7 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item } popCurrentItemFromCallStack(); + nestedRuns && nestedRuns.runs.delete(run); return self.host.dispatch(runRequestRespEvent, requestId, error || null, diff --git a/lib/runner/partition-manager.js b/lib/runner/partition-manager.js index 6121b36eb..825a196da 100644 --- a/lib/runner/partition-manager.js +++ b/lib/runner/partition-manager.js @@ -285,6 +285,7 @@ class PartitionManager { if (partition) { partition.clearPool(); + partition.abortNestedRuns(); // Per-partition cookie jar (requester.perPartitionCookieJar): // in customParallelIterations mode a stopped partition index diff --git a/lib/runner/partition.js b/lib/runner/partition.js index f05f930ee..ccca34e99 100644 --- a/lib/runner/partition.js +++ b/lib/runner/partition.js @@ -38,6 +38,10 @@ class Partition { // `variables`, the jar is not cloned from run-level state — a // fresh partition is born with an empty jar. this.cookieJar = null; + + // pm.execution.runRequest runs started by this partition's scripts, at every nesting depth. + // Replaced by abortNestedRuns when the partition is stopped. + this.nestedRuns = { aborted: false, runs: new Set() }; } /** @@ -95,6 +99,19 @@ class Partition { this.cookieJar = null; } + /** + * Aborts the nested runs (pm.execution.runRequest) still in flight for this partition. + * + * Called by stopSinglePartition. The stopped occupant's scripts keep a reference to the old + * `nestedRuns` object, so once it is flagged aborted they cannot start new nested runs, and + * nothing they do reaches the next occupant of the reused index. + */ + abortNestedRuns () { + this.nestedRuns.aborted = true; + this.nestedRuns.runs.forEach(function (run) { run.abort(_.noop); }); + this.nestedRuns = { aborted: false, runs: new Set() }; + } + /** * Creates a cursor for this partition * diff --git a/test/integration/runner-spec/run-collection-request-parallel.test.js b/test/integration/runner-spec/run-collection-request-parallel.test.js new file mode 100644 index 000000000..1846c979f --- /dev/null +++ b/test/integration/runner-spec/run-collection-request-parallel.test.js @@ -0,0 +1,419 @@ +/** + * pm.execution.runRequest under customParallelIterations: every partition (VU) of the run calls + * runRequest on its own, so each partition must get its own nested request chain and its own cookies. + * + * Runs against a LOCAL http server that records every hit, so state mixing between partitions is + * observed on the wire. + */ + +var _ = require('lodash'), + url = require('url'), + sinon = require('sinon').createSandbox(), + expect = require('chai').expect, + Collection = require('postman-collection').Collection, + VariableScope = require('postman-collection').VariableScope, + RequestCookieJar = require('postman-request').jar, + Runner = require('../../../index.js').Runner, + IS_NODE = typeof window === 'undefined', + server = IS_NODE && require('../../fixtures/servers/_servers'); + +(IS_NODE ? describe : describe.skip)('pm.execution.runRequest with customParallelIterations', function () { + this.timeout(10 * 1000); + + var httpServer, + onTarget, // optional hook called with the vu of every /target hit + hits; // [{path, vu, cookie}] in arrival order, recorded by the server + + before(function (done) { + httpServer = server.createHTTPServer(); + + httpServer.on('/main', function (req, res) { + var vu = url.parse(req.url, true).query.vu; + + hits.push({ path: '/main', vu: vu, cookie: req.headers.cookie || '' }); + res.writeHead(200, { 'content-type': 'text/plain', 'set-cookie': 'main=' + vu + '; Path=/' }); + res.end('ok'); + }); + + // delayed, so that nested requests of concurrent partitions are in flight together + httpServer.on('/target', function (req, res) { + var vu = url.parse(req.url, true).query.vu; + + hits.push({ path: '/target', vu: vu, cookie: req.headers.cookie || '' }); + onTarget && onTarget(vu); + setTimeout(function () { + res.writeHead(200, { 'content-type': 'text/plain', 'set-cookie': 'nested=' + vu + '; Path=/' }); + res.end('ok'); + }, 100); + }); + + httpServer.listen(0, done); + }); + + after(function (done) { + httpServer.destroy(done); + }); + + afterEach(function () { + onTarget = null; + sinon.restore(); + }); + + function mainCollection (testScript) { + return new Collection({ + item: [{ + id: 'main-item', + event: [{ listen: 'test', script: { exec: testScript } }], + request: httpServer.url + '/main?vu={{vu}}' + }] + }); + } + + function requestResolver (testScript) { + return function (_requestId, _context, callback) { + callback(null, { + item: { + id: 'nested-item', + event: testScript ? [{ listen: 'test', script: { exec: testScript } }] : [], + request: httpServer.url + '/target?vu={{vu}}' + } + }); + }; + } + + /** + * Drives a custom-mode run: the partitions of a wave start together, the next wave starts once + * every iteration of the current one is done, and the run is aborted after the last wave. + * + * @param {Object} opts - + * @param {Collection} opts.collection - + * @param {Object} opts.options - extra run options + * @param {Array} opts.waves - [[{ index: , vu: }]] + * @param {Function} done - (err, spies) + */ + function runWaves (opts, done) { + var spies = {}, + waveIndex = 0, + pending = 0, + run; + + hits = []; + + _.forEach(_.keys(Runner.Run.triggers), function (name) { + spies[name] = sinon.spy(); + }); + + function nextWave () { + var wave = opts.waves[waveIndex++]; + + if (!wave) { + return run.abort(_.noop); + } + + pending = wave.length; + wave.forEach(function (start) { + run.startParallelIteration(start.index, + new VariableScope({ values: [{ key: 'vu', value: start.vu }] }), _.noop); + }); + } + + new Runner({}).run(opts.collection, _.assign({ + customParallelIterations: true + }, opts.options), function (err, runInstance) { + if (err) { return done(err); } + + run = runInstance; + spies.start = sinon.spy(nextWave); + spies.iteration = sinon.spy(function () { + --pending === 0 && nextWave(); + }); + spies.done = sinon.spy(function (err) { + // A run ended by run.abort calls `done` again once timeout.global (3 minutes by default) + // elapses, with "callback timed out". Only the first call is the run's real end. + if (spies.done.callCount > 1) { return; } + + setTimeout(function () { run.host.dispose(); }, 0); + done(err, spies); + }); + + run.start(spies); + }); + } + + function passedAssertions (spies, name) { + return _.flatMap(spies.assertion.args, function (args) { + return _.map(_.filter(args[1], { name: name, passed: true }), function () { + return args[0].partitionIndex; + }); + }).sort(); + } + + describe('with a single partition', function () { + var spies; + + before(function (done) { + runWaves({ + collection: mainCollection(` + const res = await pm.execution.runRequest('nested-item'); + pm.test('nested request ok', function () { + pm.expect(res.code).to.equal(200); + }); + `), + options: { script: { requestResolver: requestResolver() } }, + waves: [[{ index: 0, vu: 'p0' }]] + }, function (err, _spies) { + spies = _spies; + done(err); + }); + }); + + it('should run the nested request and complete the run', function () { + expect(spies.done.calledOnce).to.be.true; + expect(spies.done.firstCall.args[0]).to.be.null; + expect(_.map(hits, 'path')).to.eql(['/main', '/target']); + expect(passedAssertions(spies, 'nested request ok')).to.eql([0]); + }); + }); + + describe('with partitions calling runRequest concurrently', function () { + var spies; + + before(function (done) { + runWaves({ + collection: mainCollection(` + console.log('main', pm.variables.get('vu')); + const res = await pm.execution.runRequest('nested-item'); + pm.test('nested request ok', function () { + pm.expect(res.code).to.equal(200); + }); + `), + options: { + script: { requestResolver: requestResolver('console.log(\'nested\', pm.variables.get(\'vu\'));') } + }, + waves: [[{ index: 0, vu: 'p0' }, { index: 1, vu: 'p1' }, { index: 2, vu: 'p2' }]] + }, function (err, _spies) { + spies = _spies; + done(err); + }); + }); + + it('should complete the run', function () { + expect(spies.done.calledOnce).to.be.true; + expect(spies.done.firstCall.args[0]).to.be.null; + expect(passedAssertions(spies, 'nested request ok')).to.eql([0, 1, 2]); + }); + + it('should send one nested request per partition', function () { + expect(_.map(_.filter(hits, { path: '/target' }), 'vu').sort()).to.eql(['p0', 'p1', 'p2']); + }); + + it('should tag the logs of each partition, nested ones included, to that partition', function () { + var logs = spies.console.args.map(function (args) { + return args[3] + ':' + args[0].partitionIndex + ':' + args[2]; + }).sort(); + + expect(logs).to.eql([ + 'p0:0:main', 'p0:0:nested', + 'p1:1:main', 'p1:1:nested', + 'p2:2:main', 'p2:2:nested' + ]); + }); + }); + + describe('with partitions calling runRequest inside runRequest', function () { + var spies, + depthError = 'Max pm.execution.runRequest depth of 3 has been reached.'; + + before(function (done) { + var recurse = 'await pm.execution.runRequest(\'nested-item\');'; + + runWaves({ + collection: mainCollection(recurse), + options: { + maxInvokableNestedRequests: 3, + script: { requestResolver: requestResolver(recurse) } + }, + waves: [[{ index: 0, vu: 'p0' }, { index: 1, vu: 'p1' }]] + }, function (err, _spies) { + spies = _spies; + done(err); + }); + }); + + it('should enforce the depth limit for each partition on its own', function () { + expect(spies.done.calledOnce).to.be.true; + expect(_.countBy(_.filter(hits, { path: '/target' }), 'vu')).to.eql({ p0: 3, p1: 3 }); + }); + + it('should report the depth limit on the root request of each partition', function () { + var errors = spies.exception.args.map(function (args) { + return args[0].partitionIndex + ':' + args[1].message; + }).sort(); + + expect(errors).to.eql(['0:' + depthError, '1:' + depthError]); + }); + }); + + // Not partitioned: nested runs keep the run's requester options, so their cookies behave as before + describe('without partitions', function () { + function runSequential (requester, done) { + hits = []; + + new Runner({}).run(mainCollection('await pm.execution.runRequest(\'nested-item\');'), { + iterationCount: 2, + data: [{ vu: 'p0' }, { vu: 'p0' }], + requester: requester, + script: { requestResolver: requestResolver() } + }, function (err, run) { + if (err) { return done(err); } + + run.start({ done }); + }); + } + + it('should share an explicitly passed cookie jar with nested requests', function (done) { + runSequential({ cookieJar: RequestCookieJar() }, function (err) { + if (err) { return done(err); } + + expect(hits.map(function (hit) { return hit.path + ':' + hit.cookie; })).to.eql([ + '/main:', '/target:main=p0', '/main:main=p0; nested=p0', '/target:main=p0; nested=p0' + ]); + done(); + }); + }); + + it('should give nested requests their own cookie jar when no jar is passed', function (done) { + runSequential(undefined, function (err) { + if (err) { return done(err); } + + expect(hits.map(function (hit) { return hit.path + ':' + hit.cookie; })).to.eql([ + '/main:', '/target:', '/main:main=p0', '/target:' + ]); + done(); + }); + }); + }); + + describe('with requester.perPartitionCookieJar', function () { + var spies; + + before(function (done) { + runWaves({ + collection: mainCollection('await pm.execution.runRequest(\'nested-item\');'), + options: { + requester: { perPartitionCookieJar: true }, + script: { requestResolver: requestResolver() } + }, + waves: [ + [{ index: 0, vu: 'p0' }, { index: 1, vu: 'p1' }], + [{ index: 0, vu: 'p0' }, { index: 1, vu: 'p1' }] + ] + }, function (err, _spies) { + spies = _spies; + done(err); + }); + }); + + it('should complete the run without the per-partition jar warning', function () { + expect(spies.done.calledOnce).to.be.true; + expect(spies.console.args.filter(function (args) { + return args[1] === 'warn'; + })).to.be.empty; + }); + + it('should send the calling partition\'s cookies on nested requests', function () { + expect(_.filter(hits, { path: '/target' }).slice(0, 2).map(function (hit) { + return hit.vu + ':' + hit.cookie; + }).sort()).to.eql(['p0:main=p0', 'p1:main=p1']); + }); + + it('should store cookies set by nested requests in the calling partition\'s jar', function () { + expect(_.filter(hits, { path: '/main' }).slice(2).map(function (hit) { + return hit.vu + ':' + hit.cookie; + }).sort()).to.eql(['p0:main=p0; nested=p0', 'p1:main=p1; nested=p1']); + }); + }); + + describe('with a partition stopped while its runRequest is in flight', function () { + var spies, + logs = function () { + return spies.console.args.map(function (args) { return args.slice(2).join(' '); }); + }; + + before(function (done) { + var run, + finished = false; + + hits = []; + spies = {}; + _.forEach(_.keys(Runner.Run.triggers), function (name) { + spies[name] = sinon.spy(); + }); + + // the run is over once the stopped VU's script has given up and the new VU's iteration is done + function maybeFinish () { + var all = logs(); + + if (!finished && all.includes('second new') && all.some(function (log) { + return log.startsWith('second failed old'); + })) { + finished = true; + run.abort(_.noop); + } + } + + // stop VU "old" while its nested request is on the wire, and reuse its index straight away + onTarget = function (vu) { + if (vu !== 'old') { return; } + + run.stopParallelIteration(0); + run.startParallelIteration(0, new VariableScope({ values: [{ key: 'vu', value: 'new' }] }), _.noop); + }; + + new Runner({}).run(mainCollection(` + await pm.execution.runRequest('nested-item'); + try { + await pm.execution.runRequest('nested-item'); + console.log('second', pm.variables.get('vu')); + } + catch (e) { + console.log('second failed', pm.variables.get('vu'), e.message); + } + `), { + customParallelIterations: true, + script: { requestResolver: requestResolver() } + }, function (err, runInstance) { + if (err) { return done(err); } + + run = runInstance; + spies.start = sinon.spy(function () { + run.startParallelIteration(0, new VariableScope({ values: [{ key: 'vu', value: 'old' }] }), + _.noop); + }); + spies.console = sinon.spy(maybeFinish); + spies.done = sinon.spy(function (err) { + // timeout.global calls `done` again after run.abort, see runWaves + if (spies.done.callCount > 1) { return; } + + setTimeout(function () { run.host.dispose(); }, 0); + done(err); + }); + + run.start(spies); + }); + }); + + it('should complete the run', function () { + expect(spies.done.calledOnce).to.be.true; + }); + + it('should not start new nested requests for the stopped partition', function () { + expect(logs()).to.include('second failed old pm.execution.runRequest: the iteration was stopped.'); + expect(_.filter(hits, { path: '/target', vu: 'old' })).to.have.lengthOf(1); + }); + + it('should give the next occupant of the index its own chain', function () { + expect(_.filter(hits, { path: '/target', vu: 'new' })).to.have.lengthOf(2); + }); + }); +}); diff --git a/test/unit/partition-manager.test.js b/test/unit/partition-manager.test.js index 45846b903..d87269cc0 100644 --- a/test/unit/partition-manager.test.js +++ b/test/unit/partition-manager.test.js @@ -377,6 +377,23 @@ describe('PartitionManager', function () { }); }); + it('should abort the nested runs of a stopped partition and give it a fresh set', function (done) { + var partition = partitionManager.partitions[0], + nestedRun = { abort: sinon.stub() }, + stoppedNestedRuns = partition.nestedRuns; + + stoppedNestedRuns.runs.add(nestedRun); + + partitionManager.stopSinglePartition(0, function (err) { + expect(err).to.be.null; + expect(nestedRun.abort.calledOnce).to.be.true; + expect(stoppedNestedRuns.aborted).to.be.true; + expect(partition.nestedRuns).to.not.equal(stoppedNestedRuns); + expect(partition.nestedRuns).to.eql({ aborted: false, runs: new Set() }); + done(); + }); + }); + it('should handle stopping non-existent partition gracefully', function (done) { partitionManager.stopSinglePartition(99, function (err) { expect(err).to.be.null;