diff --git a/.npmignore b/.npmignore index 182697244..509c17b41 100644 --- a/.npmignore +++ b/.npmignore @@ -51,3 +51,8 @@ docs/ codecov.yml .editorconfig .jsdoc-config.json + +# - TEMPORARY: exclude the vendored unreleased postman-sandbox build so it never +# ships in a published runtime. Remove when postman-sandbox ships the +# cycles -1 -> Infinity transform upstream (see vendor/README.md). +vendor/ diff --git a/lib/runner/extensions/event.command.js b/lib/runner/extensions/event.command.js index 1b24b1787..d205f17f6 100644 --- a/lib/runner/extensions/event.command.js +++ b/lib/runner/extensions/event.command.js @@ -40,7 +40,33 @@ var _ = require('lodash'), getCookieDomain, // fn postProcessContext, // fn sanitizeFiles, // fn - maskForbiddenSecrets; // fn + maskForbiddenSecrets, // fn + applySandboxCursorSentinel; // fn + +/** + * Replaces cursor.cycles with -1 when in customParallelIterations mode. + * + * Why -1 over the wire: the runtime sends cursor.cycles = Number.MAX_SAFE_INTEGER + * internally (so cursor.seek's bounds check accepts arbitrary iteration + * values), but exposing that to scripts would leak as + * pm.info.iterationCount === 9007199254740991. We use -1 as the wire + * sentinel because it survives JSON, structured-clone, and v8 IPC + * encodings intact — Infinity would be clobbered to null by + * JSON.stringify. The sandbox renders -1 as Infinity in pm.info.iterationCount + * (postman-sandbox/lib/sandbox/pmapi.js). + * + * @param {Object} scriptCursor - cursor object about to be passed to host.execute + * @param {Boolean} isCustomParallelIterations - the Run-level gate + * @returns {Object} either a new object with cycles=-1, or the original + * reference if no transform applies + */ +applySandboxCursorSentinel = function (scriptCursor, isCustomParallelIterations) { + if (!isCustomParallelIterations) { + return scriptCursor; + } + + return { ...scriptCursor, cycles: -1 }; +}; /** * Clones variable scopes and masks variables in forbiddenSecretKeys (Set of "scopeName:variableKey"). @@ -611,7 +637,7 @@ module.exports = { // @todo: Expose this as a property in Collection SDK's Script timeout: payload.scriptTimeout, - cursor: scriptCursor, + cursor: applySandboxCursorSentinel(scriptCursor, this.isCustomParallelIterations), context: contextToUse, resolvedPackages: resolvedPackages, @@ -769,3 +795,7 @@ module.exports = { } } }; + +// Exposed for unit tests — see test/unit/custom-parallel-iterations.test.js. +// Not part of the public runtime API. +module.exports._applySandboxCursorSentinel = applySandboxCursorSentinel; diff --git a/lib/runner/extensions/parallel.command.js b/lib/runner/extensions/parallel.command.js index b1f5744d5..ab20c1b15 100644 --- a/lib/runner/extensions/parallel.command.js +++ b/lib/runner/extensions/parallel.command.js @@ -99,23 +99,50 @@ module.exports = { // since we will never reach coords.eof for some partitions because each cursor // contains cycles for the entire run, we are breaking off early here. // this has been done to keep the contract of a cursor intact. - // cycles is defined as "number of iterations in the run" - if (coords.iteration === partition.startIndex + coords.partitionCycles) { + // cycles is defined as "number of iterations in the run". + // + // Gated on !isCustomParallelIterations: under custom mode the partition's + // cursor.iteration is the VU's per-loop counter (always equal to + // startIndex+partitionCycles on loop 2+ given startIndex=0, partitionCycles=1), + // so this guard would short-circuit every loop after the first and skip all + // items. The host (perftest) drives loop boundaries, not the runtime. + if (!this.isCustomParallelIterations && + coords.iteration === partition.startIndex + coords.partitionCycles) { this.triggers.iteration(null, payload.coords); return next(); } if (coords.cr) { - delay = _.get(this.options, 'delay.iteration', 0); - this.triggers.iteration(null, payload.coords); + + // Custom mode: the host (perftest) is the loop driver. After + // firing the iteration trigger so the host can decide what to + // do next (continue, addUsers, removeUsers, abort), return + // and let the host queue the next iteration explicitly. Do + // NOT fire beforeIteration here — startParallelIteration's + // own parallel-command invocation carries start:true and + // fires it. + if (this.isCustomParallelIterations) { + return next(); + } + this.triggers.beforeIteration(null, coords); + delay = _.get(this.options, 'delay.iteration', 0); } if (coords.eof) { - this.triggers.iteration(null, coords); + // Change 9: in customParallelIterations mode the iteration + // trigger carries "loop just completed" coords + // (payload.coords, pre-rollover) so the host can attribute + // the just-finished work to the correct iteration N. In + // runtime-managed mode (Newman/Postman desktop) the eof + // site has historically fired with the post-rollover + // snapshot — preserve that to avoid silent regressions in + // downstream subscribers we can't audit from here. + this.triggers.iteration(null, + this.isCustomParallelIterations ? payload.coords : coords); return next(); } diff --git a/lib/runner/partition-manager.js b/lib/runner/partition-manager.js index 6121b36eb..3a3185df0 100644 --- a/lib/runner/partition-manager.js +++ b/lib/runner/partition-manager.js @@ -10,6 +10,7 @@ class PartitionManager { spawn () { this.partitions = []; this.stopActionTriggered = false; + this.completionCallback = null; // we need at least one pool to start with. // this is the pool that will be used to process the control instruction @@ -128,40 +129,47 @@ class PartitionManager { } process (callback) { - if (this.runInstance.aborted) { - return callback(); - } + this.completionCallback = callback; let remainingPools = this.partitions.length, completed = false; - const poolFinished = (err) => { - if (completed) { - return; - } + const complete = (...args) => { + this.completionCallback = null; - // If run has been aborted, complete immediately - if (this.runInstance.aborted) { - completed = true; - this.runInstance.host && this.runInstance.host.dispose(); + return callback(...args); + }, + poolFinished = (err) => { + if (completed) { + return; + } - return callback(null); - } + // If run has been aborted, complete immediately + if (this.runInstance.aborted) { + completed = true; + this.runInstance.host && this.runInstance.host.dispose(); - if (err) { - completed = true; + return complete(null); + } - return callback(err); - } + if (err) { + completed = true; - remainingPools--; - if (remainingPools === 0) { - completed = true; - this.runInstance.host && this.runInstance.host.dispose(); + return complete(err); + } - return callback(null); - } - }; + remainingPools--; + if (remainingPools === 0) { + completed = true; + this.runInstance.host && this.runInstance.host.dispose(); + + return complete(null); + } + }; + + if (this.runInstance.aborted) { + return complete(); + } // First check if priority partition has items if (this.priorityPartition && this.priorityPartition.hasInstructions()) { @@ -171,7 +179,7 @@ class PartitionManager { this.priorityLock = false; if (err) { - return callback(err, this.state.cursor.current()); + return complete(err, this.state.cursor.current()); } // if custom parallel iterations is true, then do not process other partitions if (!this.options.customParallelIterations) { @@ -262,6 +270,20 @@ class PartitionManager { // Always use iteration 0 since we only have 1 iteration of data. // and start from the 0th request position. partition.cursor.seek(0, 0); + + // Clear the stop flag before queueing new work — see + // updatePartitionVariables for the late-write race this guards. + partition.stopped = false; + + // customParallelIterations: bump cursor.iteration to reflect the + // VU's current loop count so pm.info.iteration is meaningful. + // Direct assignment bypasses Cursor.seek's bounds check (safe + // because Partition._createCursor raised cycles to MAX_SAFE_INTEGER + // for this mode). Post-increment encodes the 0-indexed contract. + if (this.runInstance.isCustomParallelIterations) { + partition.cursor.iteration = partition.loopIteration++; + } + if (localVariables) { partition.variables._variables = localVariables; } @@ -286,13 +308,31 @@ class PartitionManager { if (partition) { partition.clearPool(); + // Full-fresh-on-reuse contract (agent/research/07#D3): in + // custom mode, a partition recycled via removeUsers/addUsers + // must behave as a brand-new VU. Reset the loop counter and + // re-clone variables from run-level state. + // + // partition.stopped=true also guards updatePartitionVariables + // against late writes from a script that was mid-execution + // when the stop fired — those writes would otherwise land in + // the freshly-reset scope and leak the dead VU's mutations + // into the next VU. + // + // Note: loopIteration is NOT reset on abort. perftest's + // documented lifecycle constructs one Run per session; no + // Run.start retry pattern. If that changes, a parallel reset + // hook on the abort path is the one-line fix. + // // Per-partition cookie jar (requester.perPartitionCookieJar): - // in customParallelIterations mode a stopped partition index - // is reused by the next parallel iteration — the previous - // occupant's session cookies must not leak into it. In-flight - // writers hold a reference to the old jar object, so this swap is race-safe - // — see Partition#resetCookieJar. - if (this.runInstance.options && this.runInstance.options.customParallelIterations) { + // the reused partition index must not inherit the previous + // occupant's session cookies either. In-flight writers hold a + // reference to the old jar object, so this swap is race-safe — + // see Partition#resetCookieJar. + if (this.runInstance.isCustomParallelIterations) { + partition.stopped = true; + partition.loopIteration = 0; + partition.resetVariables(); partition.resetCookieJar(); } } @@ -310,8 +350,12 @@ class PartitionManager { } if (this.options && this.options.customParallelIterations && this.runInstance.triggers) { + const callback = this.completionCallback || this.runInstance.triggers; + this.stopActionTriggered = true; - this.runInstance.triggers(null); + this.completionCallback = null; + + return callback(null); } } @@ -324,8 +368,19 @@ class PartitionManager { * @param {Object} result - The variables to update */ updatePartitionVariables (partitionIndex, result) { - if (this.partitions[partitionIndex] && result && result._variables) { - this.partitions[partitionIndex].variables._variables = new sdk.VariableScope(result._variables); + const partition = this.partitions[partitionIndex]; + + // Drop late writes from a dead VU. A script that was in flight + // at stopSinglePartition time finishes after the variables + // re-clone — without this guard its write would land in the + // fresh scope and leak the dead VU's pm.variables mutations. + // Cleared by runSinglePartition before queueing new work. + if (!partition || partition.stopped) { + return; + } + + if (result && result._variables) { + partition.variables._variables = new sdk.VariableScope(result._variables); } } } diff --git a/lib/runner/partition.js b/lib/runner/partition.js index f05f930ee..86b636745 100644 --- a/lib/runner/partition.js +++ b/lib/runner/partition.js @@ -38,6 +38,17 @@ class Partition { // `variables`, the jar is not cloned from run-level state — a // fresh partition is born with an empty jar. this.cookieJar = null; + + // Per-VU loop counter for customParallelIterations mode. Survives + // across runSinglePartition calls and feeds cursor.iteration. + // Reset by stopSinglePartition under the "full fresh on reuse" + // contract. See agent/research/07-decisions.html D3/D8. + this.loopIteration = 0; + // Set by stopSinglePartition; consulted by updatePartitionVariables + // to drop late writes from a script that was in flight at stop time + // (otherwise the dead VU's mutations leak into the next VU's fresh + // scope). Cleared in runSinglePartition before queueing new work. + this.stopped = false; } /** @@ -95,6 +106,16 @@ class Partition { this.cookieJar = null; } + /** + * Re-clones variables from run-level state. Used by stopSinglePartition + * to enforce the "full fresh on reuse" contract — when a partition is + * recycled by removeUsers/addUsers, the next loop must see a clean + * variables scope rather than inheriting the dead VU's mutations. + */ + resetVariables () { + this.variables = this._cloneVariables(); + } + /** * Creates a cursor for this partition * @@ -107,7 +128,14 @@ class Partition { _createCursor (startIteration, partitionSize, partitionIndex) { return Cursor.box({ length: _.get(this.runInstance, 'state.items.length', 0), - cycles: _.get(this.runInstance, 'options.iterationCount', 0), + // In customParallelIterations mode the loop count is unbounded + // per VU (driven by perftest's duration, not iterationCount). + // Raising cycles to MAX_SAFE_INTEGER lets us assign incremented + // iteration values via direct write or cursor.seek without + // tripping the "seeking out of bounds" guard in cursor.js. + cycles: this.runInstance.isCustomParallelIterations ? + Number.MAX_SAFE_INTEGER : + _.get(this.runInstance, 'options.iterationCount', 0), partitionCycles: partitionSize, partitionIndex: partitionIndex, iteration: startIteration, diff --git a/lib/runner/run.js b/lib/runner/run.js index 2ebc7e1fe..63d1e6a4a 100644 --- a/lib/runner/run.js +++ b/lib/runner/run.js @@ -68,6 +68,13 @@ Run = function PostmanCollectionRun (state, options) { // eslint-disable-line fu this._perPartitionCookieJarIgnored = Boolean(_.get(this.options, 'requester.perPartitionCookieJar')) && Boolean(_.get(this.options, 'requester.cookieJar')); + + // Single source of truth for the customParallelIterations gate. + // Every mode-conditional read in this file and its callers MUST go + // through this property — no `this.options?.customParallelIterations` + // sprinkled across files. If `options` is missing, the Boolean cast + // yields false; if the option is set to a truthy value, true. + this.isCustomParallelIterations = Boolean(this.options.customParallelIterations); }; _.assign(Run.prototype, { diff --git a/package-lock.json b/package-lock.json index 27f3d5d74..c2acaf08f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "performance-now": "2.1.0", "postman-collection": "5.3.1", "postman-request": "2.88.1-postman.49", - "postman-sandbox": "6.7.2", + "postman-sandbox": "file:vendor/postman-sandbox-6.7.2-per-vu-variables.tgz", "postman-url-encoder": "3.0.8", "serialised-error": "1.1.3", "strip-json-comments": "3.1.1", @@ -7902,9 +7902,10 @@ } }, "node_modules/postman-sandbox": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/postman-sandbox/-/postman-sandbox-6.7.2.tgz", - "integrity": "sha512-r/a2JMlIu92dVJMHZTL9pb8R49X23gmeNjTr6OVuwrgrjHlVLi4CQ6xCfL5PUPz/OXybhi80ll19y8CkCZRgsw==", + "version": "6.7.2-per-vu.0", + "resolved": "file:vendor/postman-sandbox-6.7.2-per-vu-variables.tgz", + "integrity": "sha512-hCAsXCw6QVSZEbzOJQo2pxcElFkKaAMxuREbhVLGloLWTM4Al8KJjWnVGZGHvqsne4p2feoTmjiMxrM18fuCXw==", + "license": "Apache-2.0", "dependencies": { "lodash": "4.18.1", "postman-collection": "5.3.1", @@ -16374,9 +16375,8 @@ } }, "postman-sandbox": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/postman-sandbox/-/postman-sandbox-6.7.2.tgz", - "integrity": "sha512-r/a2JMlIu92dVJMHZTL9pb8R49X23gmeNjTr6OVuwrgrjHlVLi4CQ6xCfL5PUPz/OXybhi80ll19y8CkCZRgsw==", + "version": "file:vendor/postman-sandbox-6.7.2-per-vu-variables.tgz", + "integrity": "sha512-hCAsXCw6QVSZEbzOJQo2pxcElFkKaAMxuREbhVLGloLWTM4Al8KJjWnVGZGHvqsne4p2feoTmjiMxrM18fuCXw==", "requires": { "lodash": "4.18.1", "postman-collection": "5.3.1", diff --git a/package.json b/package.json index a7f6b4d67..ff8e58fe8 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "performance-now": "2.1.0", "postman-collection": "5.3.1", "postman-request": "2.88.1-postman.49", - "postman-sandbox": "6.7.2", + "postman-sandbox": "file:vendor/postman-sandbox-6.7.2-per-vu-variables.tgz", "postman-url-encoder": "3.0.8", "serialised-error": "1.1.3", "strip-json-comments": "3.1.1", diff --git a/test/integration/runner-spec/customParallelIterations.test.js b/test/integration/runner-spec/customParallelIterations.test.js new file mode 100644 index 000000000..a0ddce6c0 --- /dev/null +++ b/test/integration/runner-spec/customParallelIterations.test.js @@ -0,0 +1,324 @@ +/** + * End-to-end integration tests for customParallelIterations. + * + * These tests verify the full chain: runtime drives a custom-mode loop + * via startParallelIteration → host.execute hands the (transformed) + * cursor to the sandbox → the script reads pm.info.iteration and + * pm.info.iterationCount → the runtime fires the iteration trigger and + * the driver loops or aborts. + * + * They complement the unit tests in + * test/unit/custom-parallel-iterations.test.js, which exercise the same + * logic with mocks. The integration tests are the load-bearing + * confirmation that the cross-repo wire contract (cycles=-1 → + * Infinity) survives a real host.execute round-trip — once the matching + * postman-sandbox version is pinned in package.json. + */ + +var _ = require('lodash'), + sinon = require('sinon').createSandbox(), + expect = require('chai').expect, + Collection = require('postman-collection').Collection, + Runner = require('../../../index.js').Runner; + +describe('customParallelIterations end-to-end', function () { + this.timeout(120 * 1000); // network calls to postman-echo.com + + // Guards a done-style callback so it can only fire once. Custom-mode + // runs are externally driven and torn down via abort; wrapping mocha's + // done keeps the harness resilient to any late/asynchronous re-entry. + function once (fn) { + var called = false; + + return function () { + if (called) { return undefined; } + called = true; + + return fn.apply(this, arguments); + }; + } + + // Self-contained driver that exercises the perftest invocation pattern: + // runner.run() → run.start() → startParallelIteration() loop until + // maxLoops reached → abort. + function runCustomMode (opts, done) { + var collection = new Collection(opts.collection), + runner = new Runner({}), + spies = {}, + loopCount = 0, + run; + + // fire-once guard: see note on `once` above. + done = once(done); + + _.forEach(_.keys(Runner.Run.triggers), function (name) { + spies[name] = sinon.spy(); + }); + + runner.run(collection, { + customParallelIterations: true, + iterationCount: 1, + maxConcurrency: 1 + }, function (err, runInstance) { + if (err) { return done(err); } + run = runInstance; + + spies.start = sinon.spy(function () { + run.startParallelIteration(0, null, function () { /* noop */ }); + }); + + spies.iteration = sinon.spy(function () { + loopCount += 1; + if (loopCount >= opts.maxLoops) { + run.abort(function () { /* noop */ }); + } + else { + run.startParallelIteration(0, null, function () { /* noop */ }); + } + }); + + spies.done = sinon.spy(function () { + setTimeout(function () { run.host.dispose(); }, 0); + done(null, spies, run); + }); + + run.start(spies); + }); + } + + afterEach(function () { + sinon.restore(); + }); + + describe('pm.info.iteration', function () { + var spies, assertionPasses; + + before(function (done) { + runCustomMode({ + maxLoops: 3, + collection: { + item: [{ + request: 'https://postman-echo.com/get', + event: [{ + listen: 'test', + script: { + type: 'text/javascript', + exec: [ + 'pm.test("iteration is a non-negative integer", function () {', + ' pm.expect(pm.info.iteration).to.be.a("number");', + ' pm.expect(pm.info.iteration).to.be.at.least(0);', + '});', + '// postman-sandbox@6.7.2 exposes the runtime sentinel', + '// as -1. Once the pending sandbox-side -1 → Infinity', + '// render ships, this same test asserts the new contract.', + 'if (pm.info.iterationCount === Infinity) {', + ' pm.test("iterationCount is Infinity", function () {', + ' pm.expect(pm.info.iterationCount).to.equal(Infinity);', + ' });', + '}', + 'else {', + ' pm.test("iterationCount is sentinel -1", function () {', + ' pm.expect(pm.info.iterationCount).to.equal(-1);', + ' });', + '}' + ].join('\n') + } + }] + }] + } + }, function (err, results) { + if (err) { return done(err); } + spies = results; + assertionPasses = spies.assertion.args + .reduce(function (acc, args) { + return acc.concat(args[1] || []); + }, []) + .filter(function (a) { return a.passed; }); + done(); + }); + }); + + it('drives the expected number of loops', function () { + // 3 iteration triggers — one per loop boundary. + expect(spies.iteration.callCount).to.equal(3); + }); + + it('observes monotonically increasing pm.info.iteration across loops', function () { + // Each iteration trigger carries the post-loop cursor; check + // them via runtime callback rather than from inside the script. + var iterations = spies.iteration.args.map(function (args) { + var cursor = args[1]; + + return cursor && cursor.iteration; + }); + + expect(iterations).to.eql([0, 1, 2]); + }); + + it('passes the in-script iterationCount sentinel assertion every loop', function () { + var infinityPasses = assertionPasses.filter(function (a) { + return a.name === 'iterationCount is Infinity'; + }), + sentinelPasses = assertionPasses.filter(function (a) { + return a.name === 'iterationCount is sentinel -1'; + }); + + // Current postman-sandbox@6.7.2 exposes cursor.cycles=-1 + // directly as pm.info.iterationCount. When the pending sandbox + // -1 → Infinity render ships and is pinned, the same collection + // script gates on that capability and asserts Infinity instead. + expect(infinityPasses.length + sentinelPasses.length).to.equal(3); + expect(Math.max(infinityPasses.length, sentinelPasses.length)).to.equal(3); + }); + + it('passes the in-script iteration >= 0 assertion every loop', function () { + var iterPasses = assertionPasses.filter(function (a) { + return a.name === 'iteration is a non-negative integer'; + }); + + expect(iterPasses.length).to.equal(3); + }); + + it('runs the request once per loop (no skip-everything regression on loop 2+)', function () { + // Without Change 4 the parallel-command guard at line 103 + // would short-circuit loop 2+ — no request would fire. + expect(spies.request.callCount).to.equal(3); + }); + + it('fires beforeIteration exactly once per loop', function () { + // Without Change 5's gated early-return, the cr block would + // also fire beforeIteration, doubling the count. + expect(spies.beforeIteration.callCount).to.equal(3); + }); + }); + + describe('stop + restart contract (full fresh on reuse)', function () { + it('counter resets to 0 and pm.variables are re-cloned', function (done) { + done = once(done); + + var collection = new Collection({ + item: [{ + request: 'https://postman-echo.com/get', + event: [{ + listen: 'test', + script: { + type: 'text/javascript', + exec: [ + '// Stamp a marker in pm.variables so we can detect leakage', + '// after stop+restart.', + 'pm.variables.set("dead-vu-marker", "loop-" + pm.info.iteration);', + 'pm.test("marker survives within a loop", function () {', + ' pm.expect(pm.variables.get("dead-vu-marker"))', + ' .to.equal("loop-" + pm.info.iteration);', + '});' + ].join('\n') + } + }] + }] + }), + runner = new Runner({}), + spies = {}, + run, + iterationCount = 0, + cursorObservations = []; + + _.forEach(_.keys(Runner.Run.triggers), function (name) { + spies[name] = sinon.spy(); + }); + + runner.run(collection, { + customParallelIterations: true, + iterationCount: 1, + maxConcurrency: 1 + }, function (err, runInstance) { + if (err) { return done(err); } + run = runInstance; + + spies.start = sinon.spy(function () { + run.startParallelIteration(0, null, function () { /* noop */ }); + }); + + spies.iteration = sinon.spy(function (e, cursor) { + iterationCount += 1; + cursorObservations.push(cursor && cursor.iteration); + + if (iterationCount === 2) { + // Stop the VU after 2 loops. + run.stopParallelIteration(0, function () { + // Then immediately restart. Per the contract, + // pm.info.iteration must reset to 0 and the + // pm.variables scope must be re-cloned. + run.startParallelIteration(0, null, function () { /* noop */ }); + }); + } + else if (iterationCount === 4) { + // 2 loops pre-stop + 2 loops post-restart. Done. + run.abort(function () { /* noop */ }); + } + else { + run.startParallelIteration(0, null, function () { /* noop */ }); + } + }); + + spies.done = sinon.spy(function () { + setTimeout(function () { run.host.dispose(); }, 0); + + // First two iterations: 0, 1. + // After stop+restart: 0, 1 again (full fresh). + expect(cursorObservations).to.eql([0, 1, 0, 1]); + done(); + }); + + run.start(spies); + }); + }); + }); + + describe('runtime-managed mode regression', function () { + // Make sure the unconditional changes (Change 9's eof site, + // Change 7's transform, the various gates) don't affect + // maxConcurrency mode. + it('still drives iterations to completion under maxConcurrency=2', function (done) { + var runner = new Runner({}), + collection = new Collection({ + item: [{ + request: 'https://postman-echo.com/get', + event: [{ + listen: 'test', + script: { + type: 'text/javascript', + exec: [ + 'pm.test("iterationCount is 4", function () {', + ' pm.expect(pm.info.iterationCount).to.equal(4);', + '});' + ].join('\n') + } + }] + }] + }), + spies = {}; + + _.forEach(_.keys(Runner.Run.triggers), function (name) { + spies[name] = sinon.spy(); + }); + + runner.run(collection, { + iterationCount: 4, + maxConcurrency: 2 + }, function (err, run) { + if (err) { return done(err); } + spies.done = sinon.spy(function () { + setTimeout(function () { run.host.dispose(); }, 0); + var passes = spies.assertion.args + .reduce(function (acc, args) { return acc.concat(args[1] || []); }, []) + .filter(function (a) { return a.passed; }); + + expect(passes.length).to.equal(4); + expect(spies.iteration.callCount).to.equal(4); + done(); + }); + run.start(spies); + }); + }); + }); +}); diff --git a/test/system/repository.test.js b/test/system/repository.test.js index 4eefc1242..42dbd66e6 100644 --- a/test/system/repository.test.js +++ b/test/system/repository.test.js @@ -48,7 +48,18 @@ describe('project repository', function () { }); it('should point to specific package version; (*, ^, ~) not expected', function () { - _.forEach(json.dependencies, function (dep) { + // TEMPORARY: postman-sandbox is pinned to a vendored, unreleased build via a + // `file:` spec (see vendor/README.md). Exempt only that exact spec from the + // exact-semver check. Remove this exemption when postman-sandbox ships the + // cycles -1 -> Infinity transform upstream and the dependency is re-pointed + // back to a published semver. + const vendoredSandboxSpec = 'file:vendor/postman-sandbox-6.7.2-per-vu-variables.tgz'; + + _.forEach(json.dependencies, function (dep, name) { + if (name === 'postman-sandbox' && dep === vendoredSandboxSpec) { + return; + } + expect((/^\d/).test(dep)).to.be.ok; }); }); diff --git a/test/unit/custom-parallel-iterations.test.js b/test/unit/custom-parallel-iterations.test.js new file mode 100644 index 000000000..9c80f2c51 --- /dev/null +++ b/test/unit/custom-parallel-iterations.test.js @@ -0,0 +1,631 @@ +var sinon = require('sinon').createSandbox(), + expect = require('chai').expect, + PartitionManager = require('../../lib/runner/partition-manager'), + Partition = require('../../lib/runner/partition'), + parallelCommand = require('../../lib/runner/extensions/parallel.command'), + eventCommand = require('../../lib/runner/extensions/event.command'); + +describe('customParallelIterations', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('Partition construction', function () { + var mockRunInstance; + + beforeEach(function () { + mockRunInstance = { + isCustomParallelIterations: true, + options: { + customParallelIterations: true, + iterationCount: 1, + maxConcurrency: 1 + }, + state: { + items: [{ id: 'item1' }], + environment: {}, + globals: {}, + vaultSecrets: {}, + collectionVariables: {}, + _variables: {} + } + }; + }); + + it('initializes loopIteration to 0', function () { + var partition = new Partition(mockRunInstance, 0, 1, 0); + + expect(partition.loopIteration).to.equal(0); + }); + + it('initializes stopped flag to false', function () { + var partition = new Partition(mockRunInstance, 0, 1, 0); + + expect(partition.stopped).to.equal(false); + }); + + it('sets cursor.cycles to MAX_SAFE_INTEGER in custom mode', function () { + var partition = new Partition(mockRunInstance, 0, 1, 0); + + expect(partition.cursor.cycles).to.equal(Number.MAX_SAFE_INTEGER); + }); + + it('exposes a public resetVariables() helper that re-clones from run state', function () { + var partition = new Partition(mockRunInstance, 0, 1, 0), + before = partition.variables; + + expect(partition.resetVariables).to.be.a('function'); + partition.resetVariables(); + expect(partition.variables).to.not.equal(before); + expect(partition.variables).to.have.all.keys([ + 'environment', 'globals', 'vaultSecrets', 'collectionVariables', '_variables' + ]); + }); + + describe('runtime-managed mode regression', function () { + it('keeps cursor.cycles === options.iterationCount when not in custom mode', function () { + mockRunInstance.isCustomParallelIterations = false; + mockRunInstance.options.customParallelIterations = false; + mockRunInstance.options.iterationCount = 4; + + var partition = new Partition(mockRunInstance, 0, 2, 0); + + expect(partition.cursor.cycles).to.equal(4); + }); + }); + }); + + describe('Run constructor — isCustomParallelIterations helper', function () { + var Run; + + before(function () { + Run = require('../../lib/runner/run'); + }); + + it('is true when customParallelIterations option is set', function () { + var run = new Run({}, { customParallelIterations: true }); + + expect(run.isCustomParallelIterations).to.equal(true); + }); + + it('is false when customParallelIterations option is unset', function () { + var run = new Run({}, {}); + + expect(run.isCustomParallelIterations).to.equal(false); + }); + + it('coerces truthy non-boolean values to true', function () { + var run = new Run({}, { customParallelIterations: 1 }); + + expect(run.isCustomParallelIterations).to.equal(true); + }); + }); + + describe('PartitionManager.runSinglePartition counter increment', function () { + var mgr, mockRun; + + beforeEach(function () { + mockRun = { + isCustomParallelIterations: true, + options: { + customParallelIterations: true, + iterationCount: 1, + maxConcurrency: 1 + }, + state: { + items: [{ id: 'item1' }], + environment: {}, + globals: {}, + vaultSecrets: {}, + collectionVariables: {}, + _variables: {}, + cursor: { current: sinon.stub().returns({}) } + }, + queue: sinon.stub(), + triggers: sinon.stub(), + aborted: false, + host: { dispose: sinon.stub() } + }; + mgr = new PartitionManager(mockRun); + mgr.spawn(); + // expose options on the manager — production code reads + // this.options inside runSinglePartition's siblings, set by + // createPartitions(). For custom mode, mirror that: + mgr.options = mockRun.options; + // ensure _processPartition does not actually run anything + sinon.stub(mgr, '_processPartition').callsArgWith(1, null); + }); + + it('sets cursor.iteration = 0 on first runSinglePartition (loop 1)', function (done) { + mgr.runSinglePartition(0, null, function () { + var p = mgr.partitions[0]; + + expect(p.cursor.iteration).to.equal(0); + expect(p.loopIteration).to.equal(1); + done(); + }); + }); + + it('increments cursor.iteration to 1 on second runSinglePartition (loop 2)', function (done) { + mgr.runSinglePartition(0, null, function () { + var p = mgr.partitions[0]; + + // simulate partition drained between calls + sinon.stub(p, 'hasInstructions').returns(false); + mgr.runSinglePartition(0, null, function () { + expect(p.cursor.iteration).to.equal(1); + expect(p.loopIteration).to.equal(2); + done(); + }); + }); + }); + + it('monotonically increments across 5 loops', function (done) { + var p, + expectedIterations = [], + actualIterations = []; + + function runLoop (n) { + mgr.runSinglePartition(0, null, function () { + if (!p) { + p = mgr.partitions[0]; + sinon.stub(p, 'hasInstructions').returns(false); + } + actualIterations.push(p.cursor.iteration); + expectedIterations.push(n); + + if (n === 4) { + expect(actualIterations).to.eql([0, 1, 2, 3, 4]); + + return done(); + } + runLoop(n + 1); + }); + } + runLoop(0); + }); + + it('does NOT increment in runtime-managed mode', function (done) { + mockRun.isCustomParallelIterations = false; + mockRun.options.customParallelIterations = false; + mockRun.options.iterationCount = 4; + mgr.options.customParallelIterations = false; + + mgr.runSinglePartition(0, null, function () { + var p = mgr.partitions[0]; + + // In non-custom mode, runSinglePartition is not the + // increment path. Counter stays at 0 (untouched). + expect(p.loopIteration).to.equal(0); + expect(p.cursor.iteration).to.equal(0); + done(); + }); + }); + }); + + describe('parallel.command processor — Change 4 + 5 guards', function () { + var parallelProc, ctx, partition, baseCoords; + + beforeEach(function () { + parallelProc = parallelCommand.process.parallel; + partition = { + cursor: { + whatnext: sinon.stub(), + current: sinon.stub(), + seek: sinon.stub() + }, + startIndex: 0, + partitionIndex: 0 + }; + ctx = { + isCustomParallelIterations: true, + partitionManager: { partitions: [partition] }, + state: { items: [{ id: 'item-0' }], data: null }, + triggers: { + beforeIteration: sinon.spy(), + iteration: sinon.spy() + }, + options: {}, + queue: sinon.spy(), + queueDelay: sinon.spy() + }; + // coords for "loop just rolled" — what runSinglePartition queues + // on loop 2 of a 1-item collection: cursor.iteration === 1. + baseCoords = { + iteration: 1, + position: 0, + partitionIndex: 0, + partitionCycles: 1, + cr: false, + eof: false, + empty: false + }; + }); + + describe('Change 4 — end-of-partition guard is gated on custom mode', function () { + it('does NOT short-circuit on loop 2 in custom mode (items must execute)', function () { + var next = sinon.spy(); + + parallelProc.call(ctx, { + coords: baseCoords, + static: true, + start: false + }, next); + + // The guard at line 103 must be skipped in custom mode. + // No iteration trigger from the guard; no early next(); + // the processor proceeds to queue the next item. + expect(ctx.triggers.iteration.callCount).to.equal(0); + expect(next.callCount).to.equal(0); + expect(ctx.queueDelay.callCount).to.equal(1); + }); + + it('still short-circuits in runtime-managed mode (regression)', function () { + var next = sinon.spy(); + + ctx.isCustomParallelIterations = false; + parallelProc.call(ctx, { + coords: baseCoords, + static: true, + start: false + }, next); + + expect(ctx.triggers.iteration.callCount).to.equal(1); + expect(next.callCount).to.equal(1); + expect(ctx.queueDelay.callCount).to.equal(0); + }); + }); + + describe('Change 6 — stopSinglePartition: full fresh on reuse', function () { + var mgr, mockRun; + + beforeEach(function () { + mockRun = { + isCustomParallelIterations: true, + options: { + customParallelIterations: true, + iterationCount: 1, + maxConcurrency: 1 + }, + state: { + items: [{ id: 'item1' }], + environment: {}, + globals: {}, + vaultSecrets: {}, + collectionVariables: {}, + _variables: {}, + cursor: { current: sinon.stub().returns({}) } + }, + queue: sinon.stub(), + triggers: sinon.stub(), + aborted: false, + host: { dispose: sinon.stub() } + }; + mgr = new PartitionManager(mockRun); + mgr.spawn(); + mgr.options = mockRun.options; + sinon.stub(mgr, '_processPartition').callsArgWith(1, null); + }); + + it('resets loopIteration to 0 on stop in custom mode', function (done) { + mgr.runSinglePartition(0, null, function () { + var p = mgr.partitions[0]; + + sinon.stub(p, 'hasInstructions').returns(false); + mgr.runSinglePartition(0, null, function () { + expect(p.loopIteration).to.equal(2); + mgr.stopSinglePartition(0, function () { + expect(p.loopIteration).to.equal(0); + done(); + }); + }); + }); + }); + + it('sets partition.stopped=true on stop', function (done) { + mgr.runSinglePartition(0, null, function () { + var p = mgr.partitions[0]; + + expect(p.stopped).to.equal(false); + mgr.stopSinglePartition(0, function () { + expect(p.stopped).to.equal(true); + done(); + }); + }); + }); + + it('re-clones partition.variables on stop (full fresh contract)', function (done) { + mgr.runSinglePartition(0, null, function () { + var p = mgr.partitions[0], + originalVars = p.variables; + + mgr.stopSinglePartition(0, function () { + expect(p.variables).to.not.equal(originalVars); + expect(p.variables).to.have.all.keys([ + 'environment', 'globals', 'vaultSecrets', + 'collectionVariables', '_variables' + ]); + done(); + }); + }); + }); + + it('clears partition.stopped flag on next runSinglePartition', function (done) { + mgr.runSinglePartition(0, null, function () { + var p = mgr.partitions[0]; + + mgr.stopSinglePartition(0, function () { + expect(p.stopped).to.equal(true); + sinon.stub(p, 'hasInstructions').returns(false); + mgr.runSinglePartition(0, null, function () { + expect(p.stopped).to.equal(false); + done(); + }); + }); + }); + }); + + it('counter restarts from 0 after stop+restart (regression for footgun #4)', function (done) { + mgr.runSinglePartition(0, null, function () { + var p = mgr.partitions[0]; + + sinon.stub(p, 'hasInstructions').returns(false); + mgr.runSinglePartition(0, null, function () { + expect(p.cursor.iteration).to.equal(1); + mgr.stopSinglePartition(0, function () { + mgr.runSinglePartition(0, null, function () { + expect(p.cursor.iteration).to.equal(0); + expect(p.loopIteration).to.equal(1); + done(); + }); + }); + }); + }); + }); + + it('does NOT reset counter or re-clone variables in runtime-managed mode', function (done) { + mockRun.isCustomParallelIterations = false; + mockRun.options.customParallelIterations = false; + mgr.options.customParallelIterations = false; + + mgr.runSinglePartition(0, null, function () { + var p = mgr.partitions[0], + originalVars = p.variables; + + p.loopIteration = 5; // simulate mutation + mgr.stopSinglePartition(0, function () { + expect(p.loopIteration).to.equal(5); + expect(p.variables).to.equal(originalVars); + expect(p.stopped).to.equal(false); + done(); + }); + }); + }); + }); + + describe('Change 6c — updatePartitionVariables drops late writes', function () { + var mgr, mockRun; + + beforeEach(function () { + mockRun = { + isCustomParallelIterations: true, + options: { + customParallelIterations: true, + iterationCount: 1, + maxConcurrency: 1 + }, + state: { + items: [{ id: 'item1' }], + environment: {}, + globals: {}, + vaultSecrets: {}, + collectionVariables: {}, + _variables: {}, + cursor: { current: sinon.stub().returns({}) } + }, + queue: sinon.stub(), + triggers: sinon.stub(), + aborted: false, + host: { dispose: sinon.stub() } + }; + mgr = new PartitionManager(mockRun); + mgr.spawn(); + mgr.options = mockRun.options; + sinon.stub(mgr, '_processPartition').callsArgWith(1, null); + }); + + it('writes when partition.stopped is false (happy path)', function (done) { + mgr.runSinglePartition(0, null, function () { + var p = mgr.partitions[0], + VariableScope = require('postman-collection').VariableScope, + fakeResult = { _variables: new VariableScope() }; + + mgr.updatePartitionVariables(0, fakeResult); + expect(p.variables._variables).to.not.equal(fakeResult._variables); + // It should be a new VariableScope wrapping the result + expect(p.variables._variables).to.be.instanceOf(VariableScope); + done(); + }); + }); + + it('DROPS write when partition.stopped is true (race guard)', function (done) { + mgr.runSinglePartition(0, null, function () { + var p = mgr.partitions[0], + VariableScope = require('postman-collection').VariableScope, + beforeStop = p.variables._variables; + + mgr.stopSinglePartition(0, function () { + // re-clone happened — capture the post-stop scope + var afterReset = p.variables._variables, + fakeLateWrite = { _variables: new VariableScope() }; + + expect(afterReset).to.not.equal(beforeStop); + + // simulate a script-result handler firing AFTER stop: + mgr.updatePartitionVariables(0, fakeLateWrite); + + // The freshly-reset scope MUST be untouched. + expect(p.variables._variables).to.equal(afterReset); + done(); + }); + }); + }); + + it('handles non-existent partition index without error', function () { + expect(function () { + mgr.updatePartitionVariables(99, { _variables: {} }); + }).to.not.throw(); + }); + }); + + describe('Change 5 — cr block early-returns in custom mode', function () { + it('fires iteration trigger ONCE and returns next() without queuing more work', function () { + var next = sinon.spy(), + crCoords = { ...baseCoords, cr: true }; + + parallelProc.call(ctx, { + coords: crCoords, + static: true, + start: false + }, next); + + expect(ctx.triggers.iteration.callCount).to.equal(1); + // beforeIteration MUST NOT fire — perftest's startParallelIteration + // will queue the next loop, which carries its own beforeIteration. + expect(ctx.triggers.beforeIteration.callCount).to.equal(0); + // No auto-loop: queueDelay must not run. + expect(ctx.queueDelay.callCount).to.equal(0); + // Early return. + expect(next.callCount).to.equal(1); + }); + + it('preserves auto-loop in runtime-managed mode (regression)', function () { + var next = sinon.spy(), + crCoords = { + ...baseCoords, + cr: true, + iteration: 1, + partitionCycles: 5 // not at end of partition + }; + + ctx.isCustomParallelIterations = false; + parallelProc.call(ctx, { + coords: crCoords, + static: true, + start: false + }, next); + + expect(ctx.triggers.iteration.callCount).to.equal(1); + expect(ctx.triggers.beforeIteration.callCount).to.equal(1); + expect(ctx.queueDelay.callCount).to.equal(1); + }); + }); + + describe('Change 9 — eof trigger payload normalization', function () { + it('uses payload.coords ("loop just completed") in custom mode', function () { + var next = sinon.spy(), + // payload.coords = the loop that just ended + payloadCoords = { + iteration: 2, + position: 1, + partitionIndex: 0, + partitionCycles: 1, + cr: false, + eof: true, + empty: false + }, + // coords = post-rollover snapshot from whatnext (iteration+1) + snapshotCoords = { ...payloadCoords, iteration: 3 }, + arg; + + ctx.isCustomParallelIterations = true; + // wire whatnext to return the post-rollover snapshot + partition.cursor.whatnext.returns(snapshotCoords); + + parallelProc.call(ctx, { + coords: payloadCoords, + static: false, + start: false + }, next); + + expect(ctx.triggers.iteration.callCount).to.equal(1); + // In custom mode, the trigger must carry payload.coords + // (iteration === 2, the loop that just completed), NOT + // the post-rollover snapshot (iteration === 3). + arg = ctx.triggers.iteration.firstCall.args[1]; + + expect(arg.iteration).to.equal(2); + }); + + it('preserves post-rollover coords in runtime-managed mode (regression)', function () { + var next = sinon.spy(), + payloadCoords = { + iteration: 2, + position: 1, + partitionIndex: 0, + partitionCycles: 5, + cr: false, + eof: true, + empty: false + }, + snapshotCoords = { ...payloadCoords, iteration: 3 }, + arg; + + ctx.isCustomParallelIterations = false; + partition.cursor.whatnext.returns(snapshotCoords); + + parallelProc.call(ctx, { + coords: payloadCoords, + static: false, + start: false + }, next); + + expect(ctx.triggers.iteration.callCount).to.equal(1); + // Newman/desktop mode: preserve the existing post-rollover + // behavior at the eof site. Trigger carries the snapshot + // coords (iteration === 3). + arg = ctx.triggers.iteration.firstCall.args[1]; + + expect(arg.iteration).to.equal(3); + }); + }); + }); + + describe('event.command sandbox-cursor sentinel transform — Change 7', function () { + it('exposes a helper for the cycles → -1 sentinel transform', function () { + expect(eventCommand._applySandboxCursorSentinel).to.be.a('function'); + }); + + it('replaces cycles with -1 in custom mode without mutating input', function () { + var scriptCursor = { + position: 0, + iteration: 4, + cycles: Number.MAX_SAFE_INTEGER, + partitionIndex: 0 + }, + transformed = eventCommand._applySandboxCursorSentinel(scriptCursor, true); + + expect(transformed).to.not.equal(scriptCursor); + expect(transformed.cycles).to.equal(-1); + // every other field preserved + expect(transformed.position).to.equal(0); + expect(transformed.iteration).to.equal(4); + expect(transformed.partitionIndex).to.equal(0); + // input untouched + expect(scriptCursor.cycles).to.equal(Number.MAX_SAFE_INTEGER); + }); + + it('returns the cursor unchanged in runtime-managed mode', function () { + var scriptCursor = { + position: 0, + iteration: 2, + cycles: 4, + partitionIndex: 0 + }, + transformed = eventCommand._applySandboxCursorSentinel(scriptCursor, false); + + expect(transformed).to.equal(scriptCursor); + expect(transformed.cycles).to.equal(4); + }); + }); +}); diff --git a/test/unit/partition-manager.test.js b/test/unit/partition-manager.test.js index 45846b903..3df9b0a55 100644 --- a/test/unit/partition-manager.test.js +++ b/test/unit/partition-manager.test.js @@ -399,6 +399,20 @@ describe('PartitionManager', function () { expect(mockRunInstance.triggers.calledWith(null)).to.be.true; }); + it('should settle stored completion callback when customParallelIterations is enabled', function () { + var completionCallback = sinon.stub(); + + mockRunInstance.options.customParallelIterations = true; + partitionManager.options = mockRunInstance.options; + partitionManager.completionCallback = completionCallback; + + partitionManager.triggerStopAction(); + + expect(completionCallback.calledOnceWithExactly(null)).to.be.true; + expect(mockRunInstance.triggers.called).to.be.false; + expect(partitionManager.completionCallback).to.be.null; + }); + it('should not trigger stop action when customParallelIterations is disabled', function () { mockRunInstance.options.customParallelIterations = false; partitionManager.options = mockRunInstance.options; // Ensure options are set diff --git a/test/unit/per-partition-cookie-jar.test.js b/test/unit/per-partition-cookie-jar.test.js index c85bb767e..b0ad03f13 100644 --- a/test/unit/per-partition-cookie-jar.test.js +++ b/test/unit/per-partition-cookie-jar.test.js @@ -88,6 +88,10 @@ var sinon = require('sinon').createSandbox(), describe('PartitionManager#stopSinglePartition', function () { function managerFor (isCustom) { var mockRunInstance = { + // Mirror Run's derived single-source-of-truth flag + // (see run.js: this.isCustomParallelIterations = + // Boolean(this.options.customParallelIterations)). + isCustomParallelIterations: isCustom, options: { iterationCount: 1, maxConcurrency: 1, diff --git a/test/unit/version.test.js b/test/unit/version.test.js index 9787bae11..bbd96bfd5 100644 --- a/test/unit/version.test.js +++ b/test/unit/version.test.js @@ -20,7 +20,16 @@ var expect = require('chai').expect, version: runtimePackage.dependencies['postman-request'] }, 'postman-sandbox': { - version: runtimePackage.dependencies['postman-sandbox'], + // In deep-dependency mode version() resolves the *installed* + // package version (not the package.json spec string). This + // normally coincides with the spec, but postman-sandbox is + // temporarily vendored via a file: tarball + // (file:vendor/postman-sandbox-*.tgz), so the spec no longer + // equals the resolved version. Use the installed version here. + // Revert to runtimePackage.dependencies['postman-sandbox'] + // once the sandbox change ships upstream and the dep is + // re-pointed to a published semver. + version: sandboxPackage.version, dependencies: { uvm: { version: sandboxPackage.dependencies.uvm diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 000000000..57705e474 --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,47 @@ +# Vendored dependencies (temporary) + +## postman-sandbox-6.7.2-per-vu-variables.tgz + +**Temporary vendored build — do not keep past upstream release.** + +This is a prebuilt tarball of `postman-sandbox` from branch +`feat/per-vu-variables-parallel-iterations`, based on published `6.7.2` plus +commit `75d4caf` ("feat(pmapi): render cycles -1 sentinel as +pm.info.iterationCount: Infinity"). It is packaged under the distinguishing +version `6.7.2-per-vu.0` so npm does not confuse or dedupe it with the +published `postman-sandbox@6.7.2`. + +The change makes the sandbox render the `cursor.cycles === -1` wire sentinel +(sent by this runtime under `customParallelIterations` mode) as +`pm.info.iterationCount === Infinity` instead of leaking `-1` / a raw large +number to scripts. It ships inside the built `.cache/bootcode.js` bundle (the +sandbox executes scripts from that prebuilt bundle, not from `lib/` source), +which is why the tarball was produced with the cache/bundle build step run +first. + +### How it was built + +1. Copied the sandbox worktree (`feat/per-vu-variables-parallel-iterations`, + HEAD `75d4caf`) to a temp dir — the source worktree was NOT mutated. +2. `npm version 6.7.2-per-vu.0 --no-git-tag-version` in the temp copy. +3. `npm run cache` to regenerate `.cache/bootcode.js` / `.cache/bootcode.browser.js` + from `lib/` (this is the bundle the VM actually runs). +4. `npm pack` — the resulting tarball includes `.cache/*.js` with the transform + (`iterationCount:-1===e.cursor.cycles?1/0:...`, where `1/0` === `Infinity`). + +### TODO / removal + +Once the sandbox change ships upstream in a published `postman-sandbox` +release, revert all of the temporary vendoring in this order: + +1. Re-point `package.json`'s `postman-sandbox` dependency from + `file:vendor/postman-sandbox-6.7.2-per-vu-variables.tgz` back to the + published semver range and run `npm install`. +2. Remove the `vendor/` entry from `.npmignore` (added so the tarball never + ships in a published runtime). +3. Remove the temporary `postman-sandbox` `file:` exemption in + `test/system/repository.test.js` (the `should point to specific package + version` test). +4. Revert the installed-version assertion change in + `test/unit/version.test.js`. +5. Delete this vendored tarball and the `vendor/` directory. diff --git a/vendor/postman-sandbox-6.7.2-per-vu-variables.tgz b/vendor/postman-sandbox-6.7.2-per-vu-variables.tgz new file mode 100644 index 000000000..aa5929bba Binary files /dev/null and b/vendor/postman-sandbox-6.7.2-per-vu-variables.tgz differ