Vayu Scripting Guide¶
Vayu uses QuickJS for JavaScript execution in pre-request and test scripts. The scripting API is compatible with Postman's pm object, making it easy to migrate tests from Postman.
Quick Start¶
// Test script example
pm.test('Status is 200', function() {
pm.expect(pm.response.code).to.equal(200);
});
pm.test('Response has user data', function() {
const json = pm.response.json();
pm.expect(json).to.have.property('id');
pm.expect(json.name).to.be.a('string');
});
The pm Object¶
pm.test()¶
Define a test with assertions.
Both scripts may assert. pm.test is available in a pre-request script as
well as a test script - the response is not there yet, but a
pm.sendRequest the script
just made is, and a token fetch that came back empty is worth catching before
the request goes out. Every
assertion is reported with the script that made it: each entry in the response
body's and the stored trace's testResults carries a source of "pre" or
"test", the same two spellings consoleLogs uses, and the app's Tests pane
groups the list under the script's name (issue #810). A failing assertion fails
its collection-run step from either script.
pm.test returns pm, so calls chain - Postman's contract, and what an
imported script written as one pm.test(...).test(...) expression relies on:
pm.test('status', function() {
pm.response.to.have.status(200);
}).test('body', function() {
pm.expect(pm.response.json().id).to.equal(1);
});
A callback that declares a parameter is handed done (issue #1004).
postman-sandbox reads the callback's arity to decide this and so does Vayu, so
the zero-argument form above is untouched: done() completes the test, and
done(err) fails it with err - any truthy argument, an Error being the
documented one.
pm.test('the token came back', function(done) {
pm.sendRequest(tokenRequest, function(err, res) {
if (err) { return done(err); }
pm.expect(res.json().access_token).to.be.a('string');
done();
});
});
done() must be called before the callback returns, and this is a
divergence: Postman genuinely waits. The sandbox is synchronous and drains no
job queue (see Limitations), so a done() left for later would
never run at all. A callback that declares done and returns without calling
it therefore fails, saying so - rather than being reported on a verdict
nothing ever gave. Calling done() twice is refused for the same reason the
first verdict is kept: the second call throws, and the throw fails the test
naming what happened.
pm.expect()¶
Create Chai-style expectations for assertions.
pm.expect(value); // the usual form
pm.expect(value, 'context'); // chai's second argument, see below
The optional second argument is prefixed to the failure. It is chai's
expect(value, message), and it exists for the moment the assertion fails: the
matcher says what broke and the message says which value it was - the third
item in a loop, the request that came from the MCP path rather than the app.
pm.expect(user.active, 'user ' + user.id).to.be.true;
// fails with: user 42: Expected value to be truthy
The message is coerced like chai's, and undefined / null mean "no message",
so a conditionally built one that came out absent leaves the failure text
unchanged. It prefixes assertion failures only - a call the script wrote wrong
(.to.be.above() with no argument) reports the misuse on its own, since the
message describes a value, not a typo.
A failed assertion throws an AssertionError, the name chai uses, so a
script that inspects what it caught takes the same branch it does in Postman:
try {
pm.expect(1).to.equal(2); // AssertionError: Expected 1 to equal 2
} catch (e) {
e.name; // 'AssertionError'
e instanceof Error; // true
}
pm.expect.fail([message]) fails on the spot, as an assertion rather than
as an error (issue #1004). It is chai's expect.fail, and it throws the same
AssertionError every matcher does - so outside a pm.test it aborts the
script, the way any uncaught throw does. What it changes there is the shape of
the verdict, not whether the script stops: the run is reported as a failed
assertion carrying this message, rather than as the TypeError a misuse
reports.
pm.test('no branch reached the response', function() {
if (!pm.response.json().items) {
pm.expect.fail('the list was absent'); // AssertionError: the list was absent
}
});
pm.expect.fail(); // AssertionError: expect.fail()
chai's four-argument form (fail(actual, expected, message, operator)) is
not supported: its AssertionError carries the two values it compared and
this one has nowhere to put them, so more than one argument is refused by name
rather than read as the message - which would report actual as the failure
text.
QuickJS has no AssertionError class, so this is an Error with that name
and the stack a native throw carries; no AssertionError global is exposed,
because chai's is a property of the chai module rather than a global and Vayu
does not ship that module. The same name comes off the
response assertions, which are chai in Postman too.
A mistake in the script text is still a TypeError - a matcher called with
no argument, a name nothing implements - because nothing was asserted: the call
itself was wrong.
Anything the chain does not implement throws, called or not (issue #999).
pm.expect(x).to.be.NaN is an expression statement, so before the chain was
armed a member nothing implemented evaluated to undefined and the test
reported PASS whatever the value was - a typo (.to.be.trueish) and a chai
matcher Vayu does not have (.finite, .sealed, .frozen, .extensible)
were equally silent. Every unimplemented name now raises a TypeError naming
itself, the way pm.response.to has since #487.
equal is strict, eql is deep. equal is ===, so two objects with the
same contents are not equal - only the same reference is. eql (and its alias
deep.equal) compares contents recursively and does not care about key order.
This matches chai, which is what Postman scripts are written against; picking the
wrong one is the most common porting mistake.
pm.expect({ a: 1 }).to.equal({ a: 1 }); // fails - different references
pm.expect({ a: 1 }).to.eql({ a: 1 }); // passes
pm.expect({ a: 1, b: 2 }).to.eql({ b: 2, a: 1 }); // passes - order is not part of it
The full matcher inventory:
// Equality
pm.expect(value).to.equal(expected); // strict (===)
pm.expect(value).to.eql(expected); // deep; alias .eqls
pm.expect(value).to.deep.equal(expected); // same as .eql
pm.expect(value).to.be.oneOf([a, b]);
pm.expect(value).to.be.closeTo(expected, delta);
// Truthiness and existence (paren-less: accessing them asserts)
pm.expect(value).to.be.true;
pm.expect(value).to.be.false;
pm.expect(value).to.be.null;
pm.expect(value).to.be.undefined;
pm.expect(value).to.be.ok;
pm.expect(value).to.be.empty;
pm.expect(value).to.exist;
pm.expect(value).to.be.NaN;
// Numbers (a number or a Date on both sides - nothing is coerced)
pm.expect(value).to.be.above(n);
pm.expect(value).to.be.below(n);
pm.expect(value).to.be.at.least(n);
pm.expect(value).to.be.at.most(n);
// Types
pm.expect(value).to.be.a('string');
pm.expect(value).to.be.an('array');
pm.expect(value).to.be.instanceOf(Array);
// Strings, collections and objects
pm.expect(value).to.include(item); // alias .contain
pm.expect(value).to.include({ a: 1 }); // an object target: subset match
pm.expect(value).to.have.string(substring); // string target only
pm.expect(value).to.match(/regex/);
pm.expect(value).to.have.length(n); // alias .lengthOf
pm.expect(value).to.have.property('key');
pm.expect(value).to.have.property('key', expectedValue);
pm.expect(value).to.have.nested.property('a.b[0].c');
pm.expect(value).to.have.keys('a', 'b'); // exactly these keys; alias .key
pm.expect(value).to.have.members([1, 2, 3]); // same members, any order
// Functions
pm.expect(fn).to.throw(); // alias .throws
pm.expect(fn).to.throw('message substring'); // matched against err.message
pm.expect(fn).to.throw(/pattern/);
pm.expect(fn).to.throw(TypeError);
pm.expect(fn).to.throw(TypeError, 'substring');
pm.expect(value).to.satisfy(function (v) { return v > 0; });
// Chainers
pm.expect(value).to.not.equal(expected); // negates the rest of the chain
pm.expect(value).not.to.equal(expected); // and reads either way round
pm.expect(value).to.be.above(0).and.to.be.below(10);
pm.expect(value).to.deep.include({ a: 1 }); // deep applies to include, property,
// members and oneOf as well
// Any chain word may precede any matcher - `be` and `and` assert nothing, so a
// matcher follows either of them directly
pm.expect(code).to.be.equal(200);
pm.expect(token).to.be.a('string').and.match(/^prefix_/);
pm.expect(scope).to.be.a('string').and.not.empty;
// Language chains - they assert nothing, and make a chain read as English
pm.expect(value).to.be.an('array').that.include(item);
pm.expect(value).to.be.an('array').which.have.length(n);
pm.expect(value).to.be.above(0).and.still.be.below(10);
Notes on the edges:
notsets the negation for the rest of the chain; it does not flip it. chai's rule, and the flag is never reset between assertions in one chain, so a second.notis a no-op rather than a double negative:expect(s).to.not.include("+").and.to.not.include("/")asserts thatscontains neither. It used to toggle (issue #883), which made that same line assert thatsdoes include/- a silent inversion whose direction depended only on how many times the author had written.not.deepchanges the comparison, it is not a matcher.include,property,membersandoneOfcompare strictly unless adeepappears in the chain.- The language chains assert nothing (issue #1053).
that,which,is,has,been,with,does,but,also,of,sameandstillare chai's words for making a chain read as English, and each hands the same expectation back with the chain's flags -notincluded - intact. They are accepted anywhere in a chain, so an imported collection written in the fluent style fails on the API under test rather than on the language.any,ownanditselfare not among them: each changes what the matcher after it asserts rather than reading as prose, so each still throws by name. One thing the editor cannot follow: the runtime carries every matcher on one object, while the declarations nest them underbeandhaveas the completion labels spell them, so.that.be.a('string')type-checks where chai's.that.is.a('string')does not - both run. keysmeans exactly these keys, as in chai'shave.keys. The subset form (include.keys) is not implemented;allis accepted and changes nothing,anyis not.eqlrefuses containers it cannot inspect.Map,Setand typed arrays keep their contents outside the property list, so two distinct ones report not equal rather than silently passing.Datecompares by instant andRegExpby pattern.- A throw reached through a comparison is the verdict (#1048). A key, an
array element or an array
lengthbehind a getter that throws is a read that did not happen, so the error reaches the test instead of being reported as "these differ" - which under.notwould have been a pass. That holds for the reads an assertion makes before comparing, too:include,oneOf,members,keys,property(its nested walk included),emptyandlengthstop at the read rather than answering about it. And for the rendering theDateandRegExpcomparisons run: an overriddentoJSONortoStringthat throws used to leave both sides rendered as the empty string, which compared equal. When both sides throw, the first side's error is the one reported. - A cycle fails loudly, at the depth it closes (#959). The walk carries the
pairs of objects it is already comparing, so meeting one twice is a
RangeErrornaming that repetition - not a wait for a depth limit to be reached. The 64-level cap is still there behind it, with a message of its own, as the backstop for a structure that is merely very deeply nested. eqlseparates+0from-0;equaldoes not. That is chai:equalis===, under which the two zeros are one value, whileeqlis deep-eql, whose number rule isx === y && (x !== 0 || 1/x === 1/y). The response assertions keep the other rule, because chai-postman compares them with lodash_.isEqualand lodash says equal.NaNis chai'svalue !== value, so only the numberNaNsatisfies it -expect('foo').to.be.NaNfails rather than reading the string as a number.includeon an object target is subset matching, as in chai: every key of the argument must be present on the target holding an equal value, strictly unless the chain saysdeep. Any target other than a string or an array takes an object argument, soexpect({a:1}).to.include('a')andexpect(5).to.include('x')are both aTypeError- a combination chai refuses rather than a verdict. Three edges are Vayu's own:- An expectation with no own enumerable keys is refused, where chai
passes it in both directions.
expect(body).to.include({})compares nothing, so a computed subset that came out empty would report green having asserted nothing; aDate, aRegExpand a function carry no key either and read as assertions. Each names itself instead. - A getter that throws is reported, not compared. The exception reaches
the test rather than being read as "these differ", which under
.notwould have been a pass. - A
Map, aSetor a boxedStringtarget is refused rather than answered: chai has membership rules for the first two that deep equality here deliberately does not (see theeqlnote above), and a quietfalsewas the previous answer.
- An expectation with no own enumerable keys is refused, where chai
passes it in both directions.
- The failure message names the argument.
.to.throw(TypeError)that caught aRangeErrorsays so, rather than reporting that a function which threw did not throw. - The ordering matchers type-assert both sides.
above,below,at.leastandat.mosttake a number or aDateand refuse anything else, which is chai's rule:expect('5').to.be.above(3)used to pass here throughToNumber, and now names what it was given. They compare like with like too - aDateread as milliseconds against a number is a comparison neither side wrote, so the mixed pair is refused. throwreads the error'smessage. A string or a regular expression is matched againsterr.messagealone, neverString(err)- so.to.throw('Error')no longer passes for everyErrorthrown. A constructor is accepted as the first argument (instanceof), with an optional message matcher after it. A thrown string is its own message and anything else has none, which is chai's rule:.to.throw('4')does not pass onthrow 42.- A wrong-typed value is a
TypeErrorhere where chai raises anAssertionError-expect('5').to.be.above(3),expect(5).to.include('x'). Deliberate: this file's rule is that a mistake in the script text stays aTypeErrorbecause nothing was asserted, and the response assertions refuse a wrong-typed argument the same way (#998). Both throw, so a test fails either way; what differs ise.name. .andcarries the chain's flags,notincluded, exactly as in chai. It is a language chain like.that, not a return to the start of one: a matcher may follow it directly (.and.match(/x/)), and so maynot.- The grammar is chai's: any chain word before any matcher. Every word above
to,be,have,at,and,all,not,deep,nestedand the language chains - is installed on the same expectation and hands it back, so the editor's declarations describe one type rather than the paths this page happens to spell (#1209). The examples in this section are compiled against those declarations by the app's suite, so a chain that reads correctly here is one the editor accepts.
Response Object (pm.response)¶
Access HTTP response data:
pm.response.code // Status code (number, e.g., 200)
pm.response.status // Reason phrase (string, e.g., 'OK') - see below
pm.response.responseTime // Perceived latency in ms (submit → completion).
// In load tests this includes generator-side
// queue wait. For pure server wire time, use
// responseTimeWire.
pm.response.responseTimeWire // CURLINFO_TOTAL_TIME in ms - DNS + TCP + TLS +
// send + recv. Matches the pre-v0.3 meaning of
// responseTime; use this to assert on server SLAs
// independent of generator load.
pm.response.responseTimeQueueWait // Generator-side queue overhead in ms,
// i.e. responseTime − responseTimeWire (clamped
// to >= 0). For single-shot sends this is ~0.
pm.response.headers // Plain object, lower-cased keys, with
// case-insensitive get()/has() over it - see below
pm.response.text() // Body as string
pm.response.json() // Parse JSON (throws if invalid)
pm.response.reason() // Status reason phrase ('OK', 'Not Found')
pm.response.size() // { body, header, total } in bytes
pm.response.cookies // Set-Cookie, parsed - see below
// (the stored session is pm.cookies)
pm.response.errorCode // string | undefined - TIMEOUT, DNS_ERROR, ...
pm.response.errorMessage // string | undefined - the same failure in words
errorCode and errorMessage are present only when the send failed before a
response arrived, so if (pm.response.errorCode) is the test for a transport
failure - the status code in that case is vayu's synthetic 0. A response that
reached the script from a server carries neither.
reason() reports the reason phrase from the status line. Where none was
received it falls back to the canonical text for the code, so a client-side
failure - vayu's synthetic status 0 - reads "Error" rather than an empty
string. (Postman returns null in that case; vayu always returns a string.)
status is the reason phrase, code is the number¶
Postman spells the two apart: code is the number and status is the phrase
the status line carried. Vayu used to spell both as the number, so a lifted
if (res.status === 'OK') guard was always false and
pm.expect(pm.response.status).to.eql('OK') failed where Postman passes.
status is now the phrase - the same string reason() answers, wire phrase
where there was one and the registered text for the code where there was not -
on both objects that carry a status: the response a post-request script
reads, and the one a
pm.sendRequest callback
receives. code is untouched on both.
That is a break for a script written against vayu's old spelling:
| Was | Reads now | Use instead |
|---|---|---|
pm.response.status |
"OK", not 200 |
pm.response.code |
pm.expect(pm.response.status).to.equal(200) |
fails - the phrase is not the code | pm.expect(pm.response.code).to.equal(200) |
if (pm.response.status >= 400) |
never taken - a phrase compares false against a number | if (pm.response.code >= 400) |
res.status in a pm.sendRequest callback |
the phrase | res.code |
The first two rows fail loudly: the comparison is always false, so the test
that made it goes red and names itself. The third does not. A guard
comparing status against a number is not an assertion, and JavaScript
answers false for it rather than throwing, so the branch simply stops being
entered and nothing reports that it stopped. Search a ported script for
arithmetic on status before trusting a green run.
pm.response.to.have.status(...) needs no migration - it has taken either
form since it learned the reason phrase, and decides by the argument's type.
size() counts the body the script can read through text(), so
size().body === pm.response.text().length for an ASCII body. size().header
is the serialised header block - Name: Value\r\n per header - reconstructed
from the parsed headers, so it is close to but not byte-exact with what came off
the wire. An empty body reports 0, not an absent property.
pm.response.events - a streamed run's events¶
A request sent with the Event stream setting on ("stream": true on
POST /execute) has no single response body; it has a list of events. The
post-request script runs once, after the stream has terminated, and reads
that list:
const events = pm.response.events || [];
pm.test('the server said it was done', function () {
pm.expect(events.some(function (e) { return e.event === 'done'; })).to.be.true;
});
pm.test('every token frame parsed', function () {
events.forEach(function (e) {
if (e.event === 'token') pm.expect(JSON.parse(e.data).text).to.be.a('string');
});
});
Each entry is { event, id, data }:
| Field | |
|---|---|
event |
The frame's event: name, or "message" where it carried none - the SSE spec's default, resolved by the parser so no script has to. |
id |
The origin's id:, absent when it sent none. Not the relay's own frame id, which is a different number and would make a comparison silently wrong. |
data |
The data: payload as a string, multi-line frames joined with \n. JSON.parse it yourself - the wire says nothing about what it holds. |
dataTruncated |
true only when that one event hit sseMaxEventBytes and data is a prefix. Absent otherwise. |
Buffered, never live. The sandbox is synchronous and has no event loop (see Limitations), so a per-event callback is not a feature that was left out - it is one the runtime cannot have. The script runs when the stream is over, against what was retained.
Check the markers before asserting over the whole stream. The stored list is
bounded by sseMaxStoredEvents, and a script that counted a prefix would report
a wrong number with total confidence:
if (!pm.response.eventsTruncated) {
pm.test('exactly three events', function () {
pm.expect((pm.response.events || []).length).to.equal(3);
});
}
pm.test('the stream produced something', function () {
pm.expect(pm.response.totalEvents || 0).to.be.above(0);
});
pm.response.totalEvents is every event the run received, including those
beyond the stored list; pm.response.eventsTruncated is the engine's own
comparison of the two, not something derived from a cap the script cannot see.
Both mirror the markers on the run's stored trace, because they are read from
the same node.
Absent, not empty, on an ordinary response. All three properties are missing
unless the run was a stream, so typeof separates "this was not a stream" from
"this stream produced nothing" - a distinction an empty array would erase:
A load run's deferred script reads the same list (0.17.2). POST /runs with
"stream": true samples its streaming responses like any other, and the
deferred tests script replays against those samples - so
pm.response.events, pm.response.totalEvents and pm.response.eventsTruncated
mean there exactly what they mean in design mode, and a script written for one
behaves the same in the other. Two differences follow from what a load sample
is, and both are visible to the script rather than assumed:
- The list is parsed back out of the sample's stored body, bounded by the same
sseMaxStoredEvents.totalEventsis the count taken on the wire, so it stays truthful even where the body was cut. - There is no
endReasonon this path. Under load a stream ends by server close or by one of the two caps, and nothing per sample records which - the run report'sstream.cappedcarries that fact for the run as a whole.
A sample that did not stream still reads undefined, so the typeof check
above is the one guard on both paths.
A streaming send is answered 202 before its script has run, so the results go
to the run's trace rather than into a response body - the app's Tests and
Console panes show them when the stream finishes, and
GET /runs/:runId/report carries them
under the trace's scripts node. A buffered send returns them and stores the
same object there (issue #725), so reopening either kind of run from History
shows the assertions it made.
A collection run's step is the same story with no live half at all: the run
was answered 202 when it started, so each step's results reach the app only on
its stored trace, under the same scripts node (issue #724). What a step
publishes while the run is still going is the count - tests: {passed, failed}
on its step event - because the event
ring is fixed-size and a script may make hundreds of assertions. Both count
both scripts' assertions (issue #810), which is what fails the step in the
first place: a failing pm.test ends a step whichever script made it, and the
list used to hold the test script's alone, so a step could be failed and
named in its error line by an assertion its own Tests list did not contain.
Reading response headers¶
pm.response.headers.get('Content-Type'); // case-insensitive; undefined if absent
pm.response.headers.has('X-Request-Id'); // name present, boolean
pm.response.headers.has('Content-Type', 'application/json');
// present and holding that exact value
pm.response.headers['content-type']; // indexing: exact key only
pm.response.headers.all(); // [{key, value}, ...] in map order
pm.response.headers.count(); // how many headers there are
pm.response.headers.one('Content-Type'); // {key, value}, or undefined
pm.response.headers.toObject(); // {'content-type': 'application/json', ...}
pm.response.headers.toObject(false, true); // same, keys keep the stored spelling
pm.response.headers.indexOf('Content-Type');
// position in all(), or -1
pm.response.headers.each(function (header, index, all) {
console.log(header.key, header.value, index, all.length);
});
has()'s optional second argument is compared strictly against the header's
wire value - a number never matches, since the wire value is always a string.
headers is a plain object, not Postman's HeaderList, but the read half of a
Postman PropertyList is on it: get(), has(), each(), all(), count(),
toObject(), one() and indexOf(). Every one of them that takes a header
name matches it the way HTTP header names work - case-insensitively, and
toObject()'s keys come back lower-cased for the same reason. Indexing does
not: the engine lower-cases
every response header name as it parses it, so headers['Content-Type'] is
undefined while headers.get('Content-Type') works. Prefer the methods
unless you know the exact key.
The six beyond get/has:
all()- every header as{ key, value }, in the object's own key order.count()- how many headers there are, i.e.all().length.toObject(excludeDisabled?, caseSensitive?)- a plain{name: value}object. Keys are lower-cased by default; pass a truthy second argument to keep the stored spelling instead. Postman'stoObject()lower-cases whenever the list it is called on is indexed case-insensitively, which a header list always is.one(name)- the{ key, value }member rather than its value, case-insensitive,undefinedwhen absent.getis the value half of the same lookup.indexOf(name)- the header's position inall(), case-insensitive,-1when absent. It also accepts a{ key }member (whatall()/one()hand back) in place of a name.each(fn, thisArg?)- callsfn(header, index, all)for every header, the same three arguments Postman's iterator receives;thisArgbecomes the callback'sthis. The member list is built once before the walk starts, so a callback that removes the header it was just handed does not shorten it.
All six are non-enumerable, exactly like get() and has(), so none of them
ever appear in Object.keys() or JSON.stringify().
The response object has no add/upsert/remove - the response has
already arrived, so a mutator there would only appear to change something.
A name the server sent twice reads as one value, folded with ", ". Two
Set-Cookie lines arrive as
"session=abc; Path=/, csrf=xyz; Path=/", and get() returns that whole
string - there is no list form, because headers is a plain object. Folding is
the RFC 7230 §3.2.2 equivalence for comma-list headers; before it, only the
last value of a repeated name survived at all. If you need the parts, split
on ", " - but not for Set-Cookie, whose values contain commas of their own
(Expires=Wed, 21 Oct ...). Read pm.response.cookies instead: it is that
header already parsed, boundaries and all.
all() reports the object's key order, not wire order, and can never report
a duplicate. Postman's HeaderList keeps both; this object cannot, because
it is built from a single-valued case-insensitive map and a name sent twice has
already been folded into the one entry above by the time any script sees
it - there is only ever one member to report for it.
Reading response cookies¶
pm.response.cookies.get('session'); // value, or undefined if unset
pm.response.cookies.has('session'); // boolean
pm.response.cookies.toObject(); // { session: 'abc', tracker: 't1' }
pm.response.cookies.length; // it is an array, in wire order
pm.response.cookies[0].name; // 'session'
pm.response.cookies[0].value; // 'abc'
pm.response.cookies[0].attrs; // ['Path=/', 'HttpOnly'] - raw chunks
cookies is what the response's Set-Cookie header carried, parsed - an array
of { name, value, attrs } with get() / has() / toObject() over it.
Attributes are the raw ;-separated chunks in wire order; there are no
path / secure / expires fields, because that is the header restated, and
what the engine actually holds is pm.cookies.
Three things follow from that, and they are the ones worth knowing:
- This is one response, not the session. It reports what this response set,
including a cookie the engine then discarded as expired. For what will be
sent on the next request, read
pm.cookies. - Cookie names are case-sensitive, unlike header names:
get('SESSION')does not answer thesessioncookie. That is what RFC 6265 says, and answering otherwise would be a wrong value dressed as a right one. - A name set twice answers with the last value from
get()andtoObject()- the one a browser's jar would keep - while the array still lists both, because that is what came off the wire.
The parse is shared with the app's response Cookies tab through a conformance
fixture (engine/tests/fixtures/set-cookie-conformance.json), so the value a
script asserts on and the value shown in the UI cannot drift. It handles the two
cases a naive split corrupts: a comma inside Expires=Wed, 21 Oct ... is not a
cookie boundary, and the = padding on a base64 value stays in the value.
Response Assertions¶
pm.response.to.have.status(200); // status code
pm.response.to.have.status('OK'); // reason phrase, not a code
pm.response.to.have.header('Content-Type'); // header exists
pm.response.to.have.header('Content-Type', 'text/plain'); // exact value, strict
pm.response.to.have.body('{"ok":true}'); // body equals this exactly
pm.response.to.have.body(/"ok":\s*true/); // regex run against the body
pm.response.to.have.body({ ok: true }); // parsed JSON deep-equals this
pm.response.to.have.jsonBody(); // body parses as JSON
pm.response.to.have.jsonBody('data.id'); // that property exists
pm.response.to.have.jsonBody('data.id', 42); // exists and deep-equals 42
have.status means two different things depending on the argument's type: a
number is the status code, a string is compared against the reason phrase
pm.response.reason() answers. status('200') fails - a string is always
a reason phrase to compare, never a code coerced to one. A number that is not
a whole finite code is refused, not truncated: status(200.5) used to compare
as 200 and pass against a 200, and no response carries a fractional code, so
the TypeError names what was written rather than reporting a verdict about the
status that did arrive (#1048). status(NaN) is refused the same way.
have.header's second argument is compared strictly against the header as it
arrived on the wire. header('X-Count', 5) fails rather than stringifying 5
into agreement; the expected value must already be the string form.
have.body accepts exactly three forms. A string must equal the body
exactly - this used to be a substring search. A regular expression is run
against the body text. An object is deep-equalled against the parsed JSON
body. Any other argument type - a number, a boolean - is a TypeError naming
the three accepted forms rather than a verdict: Postman silently asserts
nothing for those inputs, and Vayu refuses a silent non-assertion instead
(#998).
have.jsonBody takes an optional path and an optional value. No arguments
checks only that the body parses as JSON. A path checks that the property
exists. A path plus a value checks that the property exists and deeply
equals it - before #998 the value argument was accepted but never compared,
so a wrong expected value still passed.
Status-class assertions hang off pm.response.to.be. They are getters - the
paren-less form is the assertion:
pm.response.to.be.ok; // status 200 only
pm.response.to.be.success; // 2xx
pm.response.to.be.info; // 1xx
pm.response.to.be.redirection; // 3xx
pm.response.to.be.clientError; // 4xx
pm.response.to.be.serverError; // 5xx
pm.response.to.be.error; // 4xx or 5xx
pm.response.to.be.accepted; // 202
pm.response.to.be.badRequest; // 400
pm.response.to.be.unauthorized; // 401
pm.response.to.be.forbidden; // 403
pm.response.to.be.notFound; // 404
pm.response.to.be.rateLimited; // 429
pm.response.to.be.json; // body parses as JSON
pm.response.to.be.withBody; // body is not empty
ok is status 200 only, not any 2xx (#998) - success is the 2xx one. A
script that meant "any 2xx" and asserted .ok needs .success instead.
Postman's named statuses (accepted, badRequest, ...) also match by reason
phrase; Vayu's stay code-only.
Anything else under pm.response.to throws. A misspelled or unimplemented
name - pm.response.to.be.definitelyNotAMatcher, or the negated
pm.response.to.not.be.ok, which Vayu does not have - raises a TypeError
naming the chain rather than evaluating to undefined. A paren-less assertion
is an expression statement, so a silent undefined would report PASS against a
broken API; failing loudly is the point.
Request Object (pm.request)¶
Access request data:
pm.request.method // HTTP method (string)
pm.request.url // Full URL (Postman Url object - see below)
pm.request.headers // Request headers (object, with the methods below)
pm.request.body // Request body (Postman RequestBody object, if any - see below)
headers is a different set in each hook, and that is the point. A
pre-request script sees the composed headers - the set it is there to edit,
and the set the write-back applies back onto the request. A test script sees the
sent record: those same headers as the transfer actually issued them, which
means the ones the engine derives at send time are there too - the body-implied
Content-Type (graphql and jsonrpc -> application/json, xml ->
application/xml, x-www-form-urlencoded ->
application/x-www-form-urlencoded) and the
default headers the send added - the
User-Agent, a negotiated Accept-Encoding, and a correlation id where one is
switched on. So a test
asserting on the Content-Type a GraphQL request sent reads the header the engine
supplied, rather than the undefined it read before (#483) - with one exception
the method decides: a graphql body on a GET travels as query parameters
and sends no body at all, so there is nothing for a Content-Type to describe and
the engine derives none (issue #1228, see
the graphql envelope).
Four consequences worth knowing:
- An authored header is never overridden. The engine only derives a Content-Type the request does not declare, so what a script reads back is what its author wrote.
- A
form-dataContent-Type is absent, not blank. libcurl writes that one itself, boundary and all, so the engine suppresses an authored one and does not report as sent what it did not send. The script's view matches the response pane's Headers tab exactly. - A header with an empty value is absent too. A header line with nothing
after the colon is libcurl's spelling for remove this header, so an enabled
row whose value is empty (or only whitespace) never goes on the wire - and
the sent record does not claim it did. A pre-request script that sets a
header to
''has removed it, not blanked it; give it a value to send one. Cookieis not here. It is wire-only by design;pm.cookiesis the cookie surface, and the response's raw view is the full wire frame (which also carries libcurl's ownAccept,HostandContent-Length).
pm.request.body is Postman's RequestBody object (issue #1003), present only
when the request has a body: a bodyless request still defines no body
property at all, so typeof pm.request.body === 'undefined' still separates
"no body" from "a body that happens to be empty", exactly as it did when this
was a string.
pm.request.body.mode // 'urlencoded' | 'formdata' | 'graphql' | 'raw'
pm.request.body.raw // the body as a string, for every mode
pm.request.body.urlencoded // [{key, value, disabled}, ...] or undefined
pm.request.body.formdata // [{key, value?, type, fileName?, disabled}, ...] or undefined
pm.request.body.graphql // {query, variables?} or undefined
pm.request.body.length // the body string's own length
.mode reads raw for every content mode without a Postman name of its own -
json, text, xml, binary and jsonrpc all carry their body as one
string, which is what raw means.
Postman's fifth mode, file, is deliberately not answered: it promises
file.src, a path, and a binary body here carries bytes. The only path
this model holds belongs to a form-data file part, which is a different mode and
is never disclosed to a script (issue #411). A binary body therefore reads
raw, and that is a stated divergence rather than an omission - see
pm-api-compatibility.md.
graphql bodies (issue #1111)¶
.mode reads graphql for a GraphQL body, and .graphql answers Postman's
{query, variables} pair. Vayu stores such a body as one string that is
allowed to be either the {"query": …} envelope the request builder writes or
the bare document an agent or a curl caller hands over; the pair is derived
from that string by the same classifier the send itself goes through, so
.graphql.query is the query that goes on the wire rather than a second reading
of the same bytes.
// An enveloped body answers its own members.
pm.request.body.graphql.query // 'query User($id: ID!) { user(id: $id) { name } }'
pm.request.body.graphql.variables.id // '42'
// A bare document answers as the query it would be wrapped and sent as.
pm.request.body.graphql.query // 'query User { user { name } }'
Three things worth knowing before scripting against it:
variablesis the JSON value the envelope carries, where Postman's is the text of its variables editor. Vayu never stored that text, and serializing one here would invent whitespace and key order the user never wrote. Read it as an object; a liftedJSON.parse(…variables)is the one call to change.- A body that is envelope-shaped but does not parse answers
undefined- an unresolved{{token}}, or a mistyped envelope. The send passes such a body through untouched rather than wrapping something it could not read, and a pair invented here would be the guess it refuses..rawstill carries the string. .rawstays the whole string in this mode too, where Postman leaves it undefined - the same divergence.rawalready carries for the two form modes, and for the same reason. Assigning.rawmoves the pair with it.
A lifted pm.request.body.mode === 'raw' guard over a GraphQL body took the
true branch before this and takes the false one now. That is the break, and it
is the compatible answer: Postman names this mode too, so a script written
against Postman was already reading graphql there.
.raw is the string every mode reads as, including the two whose content is a
list of fields rather than text - and it is defined for both of those, where
Postman leaves it undefined, because a form body reading as nothing cannot be
told apart from a request with no body (issue #411). A form body's .raw reads
its enabled fields encoded key=value&…:
| Body mode | What .raw reads |
Assigning a string (or .raw) |
|---|---|---|
json / text / xml / graphql / … |
the content, as stored | replaces it |
x-www-form-urlencoded |
the encoded fields - exactly the bytes sent | parses back into the fields |
form-data |
the encoded fields, file parts as key=@filename - a rendering, not the bytes sent |
refused with a named error |
| none | the property is absent (undefined) |
sends that string as raw text |
A file part reads avatar=@portrait.png, borrowing curl's -F spelling,
because it carries its content in a path rather than a value - encoded as a pair
it would read avatar=, indistinguishable from a text part whose value happens
to be empty. The name shown is the one the server is told (the part's declared
filename, else the basename of the chosen file), never the local path. A text
value starting with @ cannot be confused with it: percent-encoding escapes
that to %40, and the marker is written unescaped.
The form-data split is not an oversight: a multipart body carries a boundary
libcurl generates at transfer time, so no faithful string exists before the send.
That makes the string safe to read and log, but a digest taken over it is not
a digest of the multipart body that goes out - and it is why an assignment is
refused there rather than accepted and then ignored by the transfer layer. To
change a multipart body, edit the request's form fields; delete pm.request.body
still drops it entirely.
.urlencoded and .formdata are the two field lists, present only in their own
mode and undefined in every other: { key, value, disabled } per
x-www-form-urlencoded pair, or { key, value?, type, fileName?, disabled } per
multipart part. Values are as the user wrote them, not percent-encoded - the
encoding is .raw's answer, so a signature built from these pairs would
double-encode if this were encoded too. A disabled row is listed with
disabled: true rather than omitted, so a script can see the row it would
otherwise re-add. A file part carries no value at all, rather than the ""
an empty text field would hold - an empty string there would read as a text
field that happens to be empty - and never the local path.
Both lists are read-only, and so are .mode and .length. Assigning any of
the four throws naming the member, and so does pushing into a list, which is
frozen. Writing to a field inside an entry is the one edit that does not throw
- a frozen object drops a write silently in non-strict code, which is
JavaScript's own rule rather than one this surface adds - and it reaches nothing
either way. Assign pm.request.body or .raw to change what is sent, or edit
the request's form fields directly.
Reading a form body never rewrites it. The write-back reads body off the
script's object whether or not the script assigned it, so an unchanged
value means untouched - without that, a script that only looked at the body
would delete the disabled rows the encoded view leaves out.
The body was a string too, and mostly still behaves as one¶
This shape replaced a plain string (issue #1003: Postman compatibility over the
shipped string shape, the same trade issue #991 made for the
URL). The object keeps as much of the old behaviour
as JavaScript allows - it carries its own toString, valueOf, toJSON and
Symbol.toPrimitive, and inherits from String.prototype:
'' + pm.request.body; // the body
`${pm.request.body}`; // the body
pm.request.body == 'plain text'; // compares as the body
pm.request.body.startsWith('{'); // String methods work
pm.request.body.length; // the body's own length
JSON.stringify({ b: pm.request.body }); // embeds the body string
Three things did change, and no mitigation can fix them:
| Was | Now | Use |
|---|---|---|
pm.request.body === '...' |
false |
==, or .toString() |
typeof pm.request.body |
'object' |
- |
pm.request.headers['X-Body'] = pm.request.body |
refused | pm.request.headers['X-Body'] = String(pm.request.body) |
The third is the same refusal pm.request.url already gets: a value the engine
cannot send is refused rather than coerced, and an object is not a header
value.
.length is not one of them: it is defined on the object as the body's own
length. Inheriting it from String.prototype - which is a String object
holding "" - would have answered 0 for a body that is not empty, and a
plausible wrong number is worse than a break you can see.
Mutating the request (pre-request scripts)¶
In a pre-request script these four fields are writable, and what they hold when the script returns is what goes on the wire. In a test script they are a read-only record of what was already sent - writes there are discarded.
pm.request.headers['X-Signature'] = 'abc123'; // add or replace a header
delete pm.request.headers['Authorization']; // remove one
pm.request.url = 'https://api.example.com/v2'; // retarget
pm.request.method = 'POST'; // case-insensitive
pm.request.body = JSON.stringify({ n: 2 }); // replace the body
delete pm.request.body; // send no body
Rules worth knowing before you rely on them:
- The object is authoritative, not a diff. The header set left in
pm.request.headersis the header set that is sent, which is what makesdeletework. - A script beats engine-applied auth. Auth (bearer / basic / apikey /
oauth2) is resolved into the request before the script runs, so the script
sees the real
Authorizationheader and can replace or remove it. - A bad value is refused, not coerced.
methodmust be a string (one of the seven HTTP verbs),urla URL string (assigning anything else throws at the assignment), header values must be strings, numbers or booleans, andbodymust be a string or theRequestBodyobjectpm.request.bodyitself holds - handing the object straight back (pm.request.body = pm.request.body) changes nothing. Anything else fails the whole write-back - the request is sent unchanged and the reason is reported as the pre-request script error, visible in the response pane's Console tab. Assigning a string to aform-databody fails the same way, for the same reason: a value the engine cannot send is refused rather than dropped. - Setting a variable can re-render the URL, if composition left it
unresolved.
{{placeholders}}are still resolved at compose time (POST /compose), strictly before any script runs (#226, D1 stands) - but a name composition could not answer keeps its braces (#1009) and is resolved again after the pre-request script and before the send (#1008), against the scopes as the script left them. Sopm.environment.set('host', …)reaches a{{host}}in this send's URL as long as nothing already answeredhostat compose time; a{{host}}composition already substituted is finished text and this pass does not touch it - assignpm.request.urldirectly to change that. - A single-request load test still does not run pre-request scripts -
only the
tests(post-request) script runs there. A scenario load run'sscript.preelement does now, but only when markedinlineor forced by the run'selements.scriptsoverride (issue #1495) - see above.
Header methods (pm.request.headers)¶
pm.request.headers.get('authorization'); // case-insensitive
pm.request.headers.has('Authorization'); // name present, boolean
pm.request.headers.has('Authorization', 'Bearer abc'); // present with that value
pm.request.headers.upsert({ key: 'X-Trace', value: id }); // add or replace
pm.request.headers.upsert('X-Trace', id); // same, two-arg form
pm.request.headers.add({ key: 'X-New', value: '1' }); // add; throws if present
pm.request.headers.remove('Authorization'); // case-insensitive
pm.request.headers.all(); // [{key, value}, ...]
pm.request.headers.count(); // how many there are
pm.request.headers.one('Content-Type'); // {key, value} or undefined
pm.request.headers.toObject(); // lower-cased keys
pm.request.headers.toObject(false, true); // keys kept as typed
pm.request.headers.indexOf('Content-Type'); // position in all(), or -1
pm.request.headers.each(function (header, index, all) {
console.log(header.key, header.value, index, all.length);
});
These act on the same object as indexing and delete, so the two styles
mix freely and the write-back sees one header set either way. They are
non-enumerable properties of it, so they never appear in Object.keys(),
JSON.stringify() or on the wire.
Behaviours worth knowing:
- The methods are case-insensitive; indexing is not.
upsert('authorization', v)replaces an existingAuthorizationrather than adding a second spelling - which matters, because the write-back refuses a header set holding two casings of one name. addrefuses a name that is already there, and says to useupsert. Postman'sHeaderListholds duplicates andaddappends one; a request here carries a single value per name, so the difference is reported rather than silently collapsed into anupsert.removeon an absent header is a no-op, not an error.has's optional value argument is a strict string compare, the same rulepm.response.headers.hasfollows - a number never matches, since the outgoing header is always a string.toObject()lower-cases its keys by default.pm.request.headerskeeps whatever casing the request holds - which can be whatever the user typed - so copying its own keys would have answeredundefinedfortoObject()['content-type']on a request carryingContent-Type. Pass a truthy second argument (toObject(false, true)) to keep the stored spelling instead.all()reports this object's key order, not wire order, and can never report a duplicate - unlike Postman'sHeaderList, which keeps both. A name assigned twice already collapsed into one entry (the second write replaced the first), so there is only ever one member forall()to report.indexOfmatches a{ key }member by itskey, not by identity. Postman finds a member by identity in its own list; the members here are built fresh on every call, so identity would answer-1for a member of the very list it came from. Matching the key answers what Postman answers for that case, and-1for an object naming a header this list does not hold.
A bad argument fails loudly: a name must be a non-empty string and a value a
string, number or boolean - the same set plain assignment accepts. each
throws if its first argument is not a function. Detaching a method from its
object (const get = pm.request.headers.get) throws rather than answering as
though the header were missing. A throw out of an each callback propagates as
the script's own error.
URL parts (pm.request.url)¶
pm.request.url is Postman's Url object, so a script lifted from Postman
reads its parts under the same names:
pm.request.url.protocol // 'https' - scheme, no colon
pm.request.url.host // ['api','example','com'] - segments
pm.request.url.port // '8443' - '' when unstated
pm.request.url.path // ['v2','users'] - decoded segments
pm.request.url.hash // 'top' - fragment, no '#'
pm.request.url.query // the query, with the reads below
pm.request.url.getHost(); // 'api.example.com'
pm.request.url.getPath(); // '/v2/users'
pm.request.url.getQueryString(); // 'page=2&sort=name' - no '?'
pm.request.url.toString(); // the whole URL
pm.request.url.query.get('page'); // first value, or null
pm.request.url.query.has('page'); // boolean
pm.request.url.query.all(); // [{key, value}, ...] in wire order
pm.request.url.query.toObject(); // {page: '2'} - last wins
pm.request.url.query.count(); // 3
Four rules behind those answers:
- Path segments are decoded, query values are not. A path is what you want
to read; a query is what you want to sign, and a canonical string has to be
built from the bytes that were sent.
getQueryString()is byte-exact against the wire. all()keeps wire order and duplicates, which is the whole reason it exists besidetoObject().get(name)answers the first match (Postman'sPropertyList.one);toObject()is last-wins and says so.- A bare
?flagreads asnull, an empty?flag=as''. Both arehas()-true. - A URL the parser cannot read has no parts.
toString()still answers the whole string, and every part is empty rather than a plausible half.
Writing¶
The whole URL - assign a string, or call update(), which is Postman's
spelling of the same write:
pm.request.url = 'https://api.example.com/v3/orders'; // re-parses in place
pm.request.url.update('https://api.example.com/v3/orders'); // the same write
Or one member at a time. path and host are live arrays, and the query has
Postman's PropertyList writers beside its reads:
pm.request.url.path.push('active'); // .../v2/users/active
pm.request.url.path[0] = 'v3'; // index assignment, splice, pop,
pm.request.url.path.length = 1; // unshift and length all work
pm.request.url.host = ['api', 'staging', 'example', 'com'];
pm.request.url.protocol = 'http'; // and port / hash likewise
pm.request.url.query.add({ key: 'trace', value: id }); // appends, duplicates ok
pm.request.url.query.add({ key: 'flag' }); // a bare ?flag
pm.request.url.query.upsert({ key: 'page', value: 4 }); // replaces in place
pm.request.url.query.remove('page'); // every match, not the first
pm.request.url.query.clear(); // and the '?' with them
Four rules behind those, each the reason for a decision you might otherwise undo:
- A URL nobody edited is sent exactly as it arrived. The parts are
recomposed only when a member was actually written to, so a read-only script
cannot change a single byte - which is what keeps
getQueryString()byte-exact against the wire. upsertkeeps wire position,addappends. A parameter that quietly moved to the end would change the shape of any signature computed over the query.remove(name)takes every match. Removingpagefrom?page=1&page=2and getting one back has removed nothing the caller can observe.- An edit that cannot reach the wire is an error, never a no-op. A URL the
parser could not read has no parts to edit, so a write is refused rather than
composing
://out of empty pieces, and a path segment that is not a string or a number is refused rather than becoming[object Object].pathandhostare ordinary arrays that are read back when the URL is needed - the same rulepm.request.headersfollows - so a bad segment surfaces as a rejected write-back, with the member and the index named, rather than at thepush.
In a test script these behave like every other pm.request write: they
change what the script sees and reach nothing, because the request has already
gone out.
It was a string, and mostly still behaves as one¶
This shape replaced a plain string (issue #991: Postman compatibility over the
shipped string shape). The object keeps as much of the old behaviour as
JavaScript allows - it carries its own toString, valueOf, toJSON and
Symbol.toPrimitive, and inherits from String.prototype:
'' + pm.request.url; // the URL
`${pm.request.url}`; // the URL
pm.request.url == 'https://a/b'; // compares as the URL
pm.request.url.startsWith('https://'); // String methods work
pm.request.url.split('?')[0]; // ... including this one
JSON.stringify({ u: pm.request.url }); // embeds the URL string
Two things did change, and no mitigation can fix them:
| Was | Now | Use |
|---|---|---|
pm.request.url === 'https://a/b' |
false |
==, or .toString() |
typeof pm.request.url |
'object' |
- |
.length is not one of them: it is defined on the object as the URL's own
length. Inheriting it from String.prototype - which is a String object holding
"" - would have answered 0 for every URL, and a plausible wrong number is
worse than a break you can see.
Script Identity (pm.info)¶
What the script is attached to, and which hook is running it. Six fields,
each optional - pm.info is always an object, but a field with no truthful
value is absent rather than "", so typeof is how a script tests for one:
pm.info.requestId // string | undefined - the saved request this send is filed under
pm.info.requestName // string | undefined - its name, as the client sent it
pm.info.eventName // 'prerequest' | 'test' | 'setup' | 'teardown'
pm.info.iteration // number | undefined - 0-based, in a run of any shape
pm.info.vu // number | undefined - 1-based, the virtual user that sent it
pm.info.iterationCount // number | undefined - the run's iteration total
pm.info.run // object | undefined - a run summary, `script.teardown` only (see below)
eventName is stamped by the engine at each hook (ScriptContext::for_prerequest
/ for_test), never by the caller, so it cannot disagree with the hook that is
actually running. The other two are supplied per send:
requestIdis the payload'srequestId, which is also what files the run in History. Absent for an ad-hoc send (MCP'srun_requestwithout one, a load run started from a URL).requestNamecomes from the payload'srequestName, falling back to the stored row's name when only an id was sent. The client sends it because Send executes editor state: an unsaved request has a name and no row to read it from, and a name edited but not yet saved should read as what the user sees.POST /composefills it in on its by-id path, so a composed payload arrives carrying it.
iteration is reported wherever a real one exists, and nowhere else. In a
design-mode scenario run (POST /runs with a scenario block and no mode)
every step's scripts read the real index - iteration counts from 0, and it
reads as 0, not as absent, on the first pass. A scenario load run's
deferred per-step script reads it too: each sampled response carries the virtual
user's iteration it was actually sent in, so the number is a fact about that
response rather than its position in a reservoir.
A single-request load run reports one too, since issue #994. It used to read
undefined there, on the rule that a reservoir position is not an iteration -
and that rule is intact, because this is not one: each submission claims its
index before it is sent, and the index travels with the response into the
sample, exactly as dataRowIndex does. It is the same counter the run's data
rows are claimed from, so a script grading a sampled response can say which
iteration produced it and which row it carried, and the two agree. An ordinary
Send still reads undefined: one request is not a pass of anything.
vu is the virtual user that sent the request, 1-based. It spans the run's
concurrency in a scenario load run, where each user walks the sequence with its
own cookies and its own iteration counter. Everywhere else it is 1, and that
is a statement rather than a placeholder: a single request repeated under load
is one user's iterations however many are in flight, and so is a collection run
in design mode. undefined on an ordinary Send, beside iteration.
iterationCount is set by the collection runner, and by a send that bound a
row. The runner reports the run's total; a send-with-row reports 1, since
that send is row 0 of 1 and says so about iteration already (see
Data rows). A load run is where it stays
undefined: a duration-bounded run has no iteration total to report, and a
field readable from one load run and not another is worse than one that is
never readable there at all.
Setup and teardown scripts¶
script.setup and script.teardown are elements a collection carries directly
(never a request), run once each at the run's own boundary rather than at any
step - run.start, before the first step or submission of either run mode, and
run.end, after the last one. They exist for the same reason k6's setup() /
teardown() and JMeter's setUp / tearDown thread groups do: fetch a token or
seed data once for the whole run, and clean up once after, without a script
attached to every request paying for it. See
elements.md for the element mechanics (dispatch, phases,
placement) - this section is what the two scripts see as scripts.
Both read and write the same three variable scopes every other script does
(pm.environment, pm.globals, pm.collectionVariables) - a name
script.setup sets is visible to the very first step or submission of the run,
because it writes through the same scopes object every other script of that
run shares, before either run mode's own work begins. pm.info.eventName reads
'setup' / 'teardown'; neither script has a pm.request or a pm.response
to read, since neither runs against a particular step. pm.execution throws
the same "not available here" sentence a non-scenario script already gets:
there is no sequence position to redirect from a run's own boundary.
script.teardown alone sees pm.info.run - a summary of the run it is
cleaning up after, since it is the one script that runs after there is one to
report:
pm.info.run.requestsSent // number - completed submissions, whole run
pm.info.run.errorRate // number - percent, whole run
pm.info.run.assertionsPassed // number
pm.info.run.assertionsFailed // number
A throwing script.setup fails the run before anything is sent. Its
outcome is checked the same way any element's is ("error" fails the check);
the run reaches Failed before either run mode's strategy starts, so nothing
is sent and no duration is measured. A throwing script.teardown is
recorded and does not change the run's status - it appears under the
report's lifecycle.teardown, exactly as a passing one does, just with an
"error" outcome and the thrown message.
Neither script's own time counts toward the run's duration figures. Setup
runs, and is timed out of the way, before either run mode captures its own
test_start; teardown runs after the load path's test_end is already
captured, and after the sequential run's own step loop has finished.
pm.sendRequest follows the run's own gate, with no separate toggle.
Vayu's MCP target allowlist is what makes every script-issued request refuse
by default (see Sending a request from a script); a
setup or teardown script is bound to the same ScriptEngine instance, and
therefore the same allowScriptRequests decision, every other script of the
run already answers to - there is no script.setup-only opt-in to reach for,
and the same 10 requests per script cap applies.
Environment Variables (pm.environment)¶
Access and modify environment variables:
// Get variable
const token = pm.environment.get('auth_token');
// Set variable (persists to environment)
pm.environment.set('auth_token', 'new_token_value');
set() on an existing name replaces only its value - the variable's secret,
enabled, type and creation time are kept. A name that does not exist yet is
created with the defaults and stamped with its creation time, so it appears at
the bottom of that scope in the variables editor rather than above the rows
that were already there. A scope no script wrote is not persisted at all.
The six methods every scope has¶
| Method | Returns | Notes |
|---|---|---|
get(name) |
the value, cast by its declared type, or undefined |
a disabled variable reads as undefined |
set(name, value) |
undefined |
keeps secret / enabled / type / creation time |
has(name) |
boolean |
true only for the rows get() can read, so a disabled variable is false |
unset(name) |
undefined |
removes the name; removing one that is not there is not an error |
clear() |
undefined |
empties this scope only, disabled rows included |
toObject() |
plain object | every enabled variable, values cast by type; a snapshot, not a live view |
if (pm.environment.has('auth_token')) {
pm.environment.unset('auth_token');
}
console.log(pm.environment.toObject());
unset() is not the same as set(name, ''). An emptied variable is still an
enabled row, so {{auth_token}} resolves to the empty string; an unset one is
gone, and the template resolves as it does for a name nobody defined. The
removal reaches disk the same way any other write does - the scope is rewritten
after the run because the map the script left differs from the stored one.
A scope the run was not given (a design run with no active environment, say)
behaves as an empty one: get is undefined, has is false, toObject is
{}, and writes go nowhere. A script cannot see which scopes a run carries, so
this is deliberately not an error.
Collection and Global Variables¶
The other two scopes are reached the same way as the environment, each through its own accessor, and answer the same six methods:
const value = pm.collectionVariables.get('baseUrl');
pm.collectionVariables.set('baseUrl', 'https://api.example.com');
const runId = pm.globals.get('run_id');
pm.globals.set('run_id', '42');
Each set() persists to the scope it names, with the same
keep-the-flags behaviour described for pm.environment above.
pm.collectionVariables reads the request's whole collection chain, the
same merge {{name}} resolution uses: get, has and toObject take the
nearest enabled definition, walking from the request's own collection up to the
root. A variable defined on a parent collection therefore reads the same in a
script as it substitutes in a URL.
Writes stay on the request's own collection. set, unset and clear
never reach an ancestor, so set on a descendant shadows an inherited name
and unset un-shadows it - the ancestor's value comes back rather than being
deleted. A disabled row is looked past wherever it sits in the chain. The full
rule, and why ancestors are read-only, is in
Variable Resolution.
Variables (pm.variables)¶
pm.variables reads a name without naming its scope, resolving the bound
data row first (if one is bound), then environment, then collection, then
global, and stopping at the first tier that has it. That is the same order
{{baseUrl}} is resolved in before the request is sent (see
Variable Resolution), so a script and a URL in
the same request cannot read one name two different ways.
While a data row is bound, its bare column names answer first (issue
1007) - above environment, collection and globals. Postman binds a dataset's¶
columns bare, so an imported data-driven collection is written
{{username}} rather than {{data.username}}, and pm.variables.get,
.has and .toObject read the row before any scope for exactly that reason:
// In a data-driven run, with a row carrying username and city:
pm.variables.get('username'); // this iteration's `username` cell, typed
pm.variables.has('username'); // true, even if an environment var of the
// same name exists - the row wins
get and toObject hand the row's cell back typed, exactly as
pm.iterationData.get does - a number column reads as a number, not a
stringified one. A name the bound row does not carry is not a miss on this
tier alone; it falls through to environment, collection and globals precisely
as it would with no data file at all, which is what lets one script run both
in a data-driven run and a plain Send. With no row bound, this tier is simply
absent and pm.variables behaves exactly as it always has. The reserved
data. prefix is not part of this tier at all - pm.variables.get("data.userId")
still answers undefined regardless of what row is bound (see below).
const baseUrl = pm.variables.get('baseUrl'); // wherever it is defined
if (pm.variables.has('debug')) { /* ... */ }
console.log(pm.variables.toObject()); // the bound row, then all three scopes, merged
It has get, has and toObject - and no unset or clear, because it owns
no scope to remove a name from (the row is read-only too - see
pm.iterationData below).
pm.variables.set() throws. In Postman it writes to a local scope that
lives for one request and is never stored; Vayu has no such scope. Writing to
the environment instead would persist a value the script author expects to
vanish, and quietly dropping the call would lose a write they believe happened,
so it fails loudly and names the three scopes that do exist:
TypeError: pm.variables.set is not supported: Vayu has no local variable scope.
Use pm.environment.set(), pm.collectionVariables.set() or pm.globals.set() to
choose where the value is stored.
pm.variables.replaceIn(template) - Postman's {{name}} interpolation of
an arbitrary string - runs the same resolver POST /compose uses
(request_composer.cpp::resolve_template), over the script's scopes in
pm.variables' precedence, at call time - so a variable the script set a
line earlier resolves, unlike {{}} in the URL, which was composed before the
script started. Dynamic variables generate per occurrence:
It exists only on the merged accessor - the scoped replaceIn variants stay
absent - and its argument must be a string (a non-string is a TypeError).
This is the one sanctioned way to {{...}} in a script: script source is
never interpolated (issue #226, D16 - a rewrite cannot tell code from a string
literal, and splicing values into source is an injection). The editor now
paints that difference rather than leaving it to this page alone: a
replaceIn(...) template's tokens get the full colour and popover a body
field's do, while a bare {{name}} written anywhere else paints muted and its
hover names this section and pm.variables.replaceIn as the fix (issue #1220
script support, docs/app/COMPONENTS.md).
It resolves the reserved data namespace too (issue #890), which composition
deliberately does not: resolve_template leaves {{data.column}} written as it
stands because a plan is composed once, before any row is bound, while this runs
per step with the iteration's row in hand. A column the row does not carry is a
TypeError naming the token and the row's columns - the bind-time rule
(apply_iteration_template) in a shape a script can catch - and with no row bound at
all the token keeps its braces.
The reserved data. prefix is still not a variable scope, though.
pm.variables.get("data.userId") and .has(...) answer undefined / false
regardless of what row is bound - data. is disjoint from the variable scopes
by design (core/scenario_data.hpp), and pm.iterationData remains its only
accessor. That is a different question from the bare column names above:
pm.variables.get("userId") does read the bound row (issue #1007), so only
the prefixed spelling stays outside pm.variables entirely. replaceIn
resolves both spellings because it renders a template, and {{data.userId}}
is a token template syntax has - get and .has are lookups by name, and
data.userId was never a name any scope, or now the row, answers to.
The identity namespace resolves from replaceIn too (issue #1057).
{{$vu}} and {{$iteration}} render to numbers, the same way {{data.column}}
does above: the resolver takes the identity the request beside the script was
bound with, ahead of every scope and ahead of the row, so replaceIn and the
request cannot disagree about one send. Which numbers those are is the run's
own question, answered per shape in
the binding table:
a collection run in design mode is one user walking the sequence, so {{$vu}}
renders 1 while {{$iteration}} advances with the pass. On a plain Send the
numbers are 1 and 0, because POST /execute binds exactly those into every
send that carries no row of its own (a single send is a run of one, issue
994) - and it binds them before the pre-request script runs, so they are what¶
the request the script is handed already carries.
That is not the same fact pm.info.vu and pm.info.iteration
carry, and the two staying different is not a contradiction to resolve: pm.info
answers which iteration of which run this script is running in, and reads
undefined on that same plain Send because there is no run to be an iteration
of. A token in a template answers what it resolves to here, and here it
resolves to what the request beside it carried - a run of one has an identity
even though it has no pm.info.
Dynamic variables are otherwise not readable from a script. {{$guid}},
{{$timestamp}} and the rest of the set in
variable resolution are
generated while the payload is composed; by the time a script runs, that
payload holds the generated value and no scope has ever heard of the name.
pm.variables.get("$guid") reads as any other undefined name does - reach the
generators through replaceIn.
Sending a request from a script (pm.sendRequest)¶
The one part of the sandbox that touches the network. Its reason for existing is the token fetch: a pre-request script that needs a credential the request itself cannot supply.
pm.sendRequest(
{
url: "https://auth.example.com/token",
method: "POST",
header: { "Content-Type": "application/json" },
body: { mode: "raw", raw: JSON.stringify({ client_id: "abc" }) },
},
function (err, res) {
if (err) {
console.error("token fetch failed: " + err.message);
return;
}
pm.environment.set("token", res.json().access_token);
}
);
The first argument is a URL string or an options object; the second is required and must be a function.
| Option | Shape |
|---|---|
url |
string, required, or pm.request.url - the Url object, so pm.sendRequest(pm.request.url, cb) reads as "send this again". A hand-built { host, path } object is still not accepted |
method |
string, default GET; case-insensitive, an unknown verb throws |
header / headers |
{ name: value } or Postman's [{ key, value }]. Both names read; sending both at once throws |
body |
a string, or { mode: 'raw', raw }. Only raw - other modes throw |
auth |
Postman's { type, <type>: params }. basic, bearer, apikey, noauth; any other type throws |
timeout |
milliseconds, clamped to the script's remaining budget (below) |
Synchronous, and callback-shaped for that reason. The send blocks and the
callback runs inline, before pm.sendRequest returns. There is no promise
overload: Promise exists in the sandbox but nothing drains its job queue, so
one could only never resolve - the same reason hashing is pm.crypto rather
than crypto.subtle.
Which failures throw and which reach the callback. Transport failures are
the network's answer, so they arrive as the callback's err - an Error with
a .code (CONNECTION_FAILED, DNS_ERROR, TIMEOUT, …) and res null. The
script's own mistakes throw out of the call instead: an unusable argument, an
unsupported body mode, exceeding the request cap, and the capability being off.
A response over the byte bound below is the network's answer too, not a
mistake: it arrives as err with .code RESPONSE_TOO_LARGE.
res carries code, status (the reason phrase, as on pm.response),
responseTime, headers with get()/has()/each()/all()/count()/
toObject()/one()/indexOf() - the same read methods documented under
Reading response headers - json() and text().
It is a subset of pm.response and has no to.* assertion chain.
Three bounds, all hard.
- The script's deadline. The wall-clock limit is enforced by a QuickJS
interrupt handler, and QuickJS only calls it between bytecode operations -
a blocking C function never yields to it. So the request's timeout is clamped
to whatever is left of the script's budget; without that, a 5s script calling
pm.sendRequestat the default 30s request timeout would hold its thread for 30s with no error and no way to interrupt it. WhenscriptTimeoutis0there is no budget and nothing to clamp to. A call made with the budget already spent is refused by name -pm.sendRequest was called with none of the script's time budget left- and that sentence is what the script's error reports, since the handler never ran and so never stopped anything. - A request cap. One script execution may issue at most 10 requests, then
throws. A load run's
testsscript runs once per sampled response, serially, on the run's worker thread, so an uncapped loop would turn post-run validation into minutes of apparent hang. - A response byte bound. The fetch reads at most what the enclosing execution
reads, and refuses past it - the callback's
errsaysResponse is N bytes, over the M byte limit, andresis null. Which setting suppliesMfollows the path: a design-mode send's scripts (Send, a collection-run step) takemaxDesignResponseBodyBytes, a load run's deferredtestsscript takesmaxResponseBodyBytes, because a script that runs once per sampled response belongs to the run's memory budget rather than to the one sized for a body a person is about to look at. Refusing rather than handing over a prefix is the deliberate half:reshas no truncation flag - nor doespm.response- so a cut body would reachJSON.parseas corrupt input with nothing to say why. Before issue #1188 this fetch was the one read in the engine with no bound at all.
It leaves the way its execution leaves. The fetch takes the transport policy
its enclosing execution resolved - the proxy mode and URL, the bypass list, the
custom CA bundle and the client-certificate registry - rather than whatever the
daemon's own environment would pick up. A script that authenticates through
pm.sendRequest and then lets the real request carry the session has to take the
same route out of the machine, or one of the two is unreachable behind a
corporate proxy (issue #705). Which policy that is follows the path, as the byte
bound above does: a design-mode send's scripts take the one resolved for that
send, and a load run's deferred tests script takes the one the run's own
transfers left by - resolved once when the run starts and kept for it, so a
Settings edit made while the run was in flight cannot send an assertion by a
route the responses it is asserting on never took (issue #1256).
Not available to agents. Vayu's MCP target allowlist is checked in the MCP
server, against the composed URL, before it calls the engine - so a script-issued
request never passes that gate. The engine therefore refuses script-issued
requests unless the caller explicitly asks for them (allowScriptRequests on
POST /execute / POST /runs); Vayu's own Send and load runs ask, and the MCP
server never does. Calling it from an agent-started run throws a message saying
so. See MCP.
The flag is a property of who asked for the execution, not of the shape its
answer comes back in: POST /execute reads it before it branches on stream,
so a streaming send's pre- and post-request scripts are governed by exactly the
bit a buffered send's are (issue #653). Pressing Send with the Event stream
setting on and off gives pm.sendRequest the same answer.
{{variables}} resolve as the call is made (#1001). The URL, each header
name and value, a raw body and each credential of an auth block are resolved
once, against the three scopes and the bound data row exactly as
pm.variables.replaceIn reads them - so a value this same script set two lines
earlier is visible, which is Postman's rule and what makes an imported
token-refresh script work. It is not a second pass over the composed request:
that payload was resolved before the script ran and nothing here revisits it.
A name nothing defines keeps its braces (#1009), and a {{data.column}} the
bound row lacks throws naming the column, the same way replaceIn does.
Header names resolve too (#1067), under the collision rule composition owns
rather than a second one written here (#1051, http/header_names.hpp): two
names that resolve to one name would send the request a header short, so the
call throws naming both spellings and the name they produced, and nothing goes
out. Names are compared without case, the way the header map keys them. A name
that resolves to nothing at all is refused the same way, and it is the one thing
resolution can produce that nothing further down the send would catch: the
pre-send gate reads header text for the bytes that break a line, and what is
left of an empty name is the line : value, which libcurl sends. This call met
that rule first, a script writing header names of its own; since #1084
composition and the execute-time residual pass refuse it in the same words, so
one wording answers wherever a name is resolved.
pm.environment.set("tenant", "acme");
pm.sendRequest(
{
url: "{{baseUrl}}/{{tenant}}/token",
method: "POST",
auth: { type: "basic", basic: { username: "{{id}}", password: "{{secret}}" } },
},
function (err, res) {
if (err) {
return;
}
pm.environment.set("token", res.json().access_token);
}
);
auth is composed, or refused by name. The block takes Postman's
{ type, <type>: params } shape, with the parameters in either spelling Postman
writes - the exported [{ key, value }] array, or a plain object. basic,
bearer and apikey (in: 'header' by default, 'query' for a parameter) go
through vayu::http::apply_auth, the engine's one auth composer, so the header
or the percent-encoded query parameter is the one every other send would have
written, and an Authorization header the script set itself still wins. Every
other type throws naming it - oauth2 included, because acquiring its token
needs the database this path deliberately does not carry. So does a type whose
block is not there: { type: 'basic' } with no basic beside it, or the key
misspelled, is refused rather than composed as the empty credential basic's two
optional halves would otherwise make of it. Dropping the option would send an
unauthenticated request that looks like the script's own mistake, which is the
reason the body modes are refused too.
The cookie jar (pm.cookies)¶
pm.cookies.get('session'); // value, or undefined
pm.cookies.has('session'); // boolean
pm.cookies.toObject(); // { session: 'abc' }
pm.cookies.each(function (cookie) { console.log(cookie.name); });
pm.cookies.all(); // array of cookie objects
pm.cookies.count(); // number
pm.cookies is what the engine is holding for this request's URL - matched on
domain, path, Secure and expiry, exactly as it will be sent. It is what makes
"log in once, reuse the session" work: a Set-Cookie on one request is carried
to the next one automatically, with no header to set by hand.
each, all() and count() are Postman's CookieList reads, over the same
matched set get/has/toObject answer over - what this request's URL would
carry. Each call reads the jar afresh, so a jar().set earlier in the script is
visible by the time one of these runs. each(fn, context?) calls fn once per
cookie with the whole cookie object (below), context becoming the iterator's
this; a throw from fn - a failed pm.expect inside it, most likely - ends
the walk and is the script's error rather than being swallowed. all() returns
an array of cookie objects, count() the number of them.
A cookie object - what each, all(), jar().getAll() (below) and
jar().set's callback all hand back - carries:
| Field | Value |
|---|---|
name, key |
the cookie's name, under both spellings. Postman's Cookie calls it key; both are present, same value |
value |
the cookie's value |
domain, path |
where it is scoped |
secure, httpOnly |
booleans |
hostOnly |
true when the cookie answers for that host only, false when it answers for subdomains too |
session |
true for a session cookie |
expires |
a Date, or null for a session cookie |
Postman's maxAge and its unmodelled extensions are deliberately absent: the
jar does not keep them, and a field with nothing behind it is a value a script
would read and act on.
- One jar per environment, plus one for requests sent with no environment selected. A staging session therefore cannot ride along on a production call even when both point at the same host - which cookies alone would not prevent, since they ignore the port and the scheme.
- In memory only, for as long as the engine runs. Nothing is written to disk: a stored jar is credential-grade material. Settings → General → Cookies shows every jar and clears them.
pm.sendRequestshares the jar of the request it runs inside. A pre-request script that logs in through it leaves the session where the real request will find it.- Load runs mostly have no jar. The jar is deliberately off the load path:
sharing one across the event loop's workers would put a lock on the hot
path. One exception (issue #1501): a scenario load run's own inline
script.*element reads that one virtual user's own cookies through the flat surface above - the same per-user state the run already carries step to step outside any script (docs/engine/architecture.md's Cookie Jar section, "Not on the load path"), with no lock, because that user's own completion is the only reader. A single-request run (no scenario, no virtual user) and a scenario step's deferred replay (a recorded response, not the user that fetched it) still throw there, each naming its own reason rather than the one sentence above. - Writing goes through
jar(), below. There is deliberately no flatpm.cookies.set(name, value): a written cookie needs a URL to take its domain and path from, which is exactly why Postman's write half hangs off the jar object.
The jar is libcurl's own cookie engine underneath: matching, expiry and replacement are its rules, not a second implementation of RFC 6265.
Writing to the jar (pm.cookies.jar())¶
const jar = pm.cookies.jar();
jar.set(pm.request.url, { name: 'session', value: token });
jar.set(pm.request.url, 'session', token); // the same, flat
jar.get('https://api.example.com/', 'session'); // value, or undefined
jar.getAll('https://api.example.com/'); // every cookie that URL would carry
jar.unset('https://api.example.com/', 'session');
jar.clear('https://api.example.com/'); // that URL's cookies
jar.clear(); // this environment's jar
Postman's jar object, whole - getAll is new, Postman's "dump the session"
read: every cookie a request to that URL would carry, as an array, whole. It is
exactly what get(url, name) matches, without a name to narrow it - the same
domain/path/Secure/expiry rules, the same per-environment jar, and a cookie
this script has already staged with set is included. Every method is
URL-scoped - it takes the URL the cookie belongs to rather than assuming
this request's (clear's URL is optional; see below) - and each accepts an
optional trailing callback, invoked inline the way
pm.sendRequest's is, since
the work has already happened by the time it is called. What the callback
carries, and what the call itself returns, is what that call did - there is no
longer one shape for all five:
| Method | Callback / return |
|---|---|
get(url, name) |
the value, or undefined |
getAll(url) |
every matching cookie, as an array of cookie objects |
set(url, cookie) |
the stored cookie object - the one thing this call knows and the script does not, since it carries the domain and path derived from the URL where cookie left them out |
unset(url, name) |
the removed name |
clear(url?) |
undefined - there is nothing left to describe |
The cookie object needs name and value; everything else is optional and
defaults from the URL:
| Field | Default |
|---|---|
domain |
the URL's host, host-only. A leading dot (.example.com) means subdomains too |
path |
RFC 6265 default-path - the URL's path with its last segment removed, so /v1/orders/42 gives /v1/orders |
secure, httpOnly |
false |
expires |
0, a session cookie. Otherwise a Date, a date string, or a whole number of seconds since the epoch |
expires takes three spellings, matching Postman: a Date; a date string -
anything JavaScript's own Date.parse accepts, an ISO 8601 or an HTTP date; or
a whole number of seconds since the epoch, 0 still meaning a session cookie.
The Date and the string are read by asking QuickJS's own Date (getTime and
Date.parse) rather than a date parser written into the engine, so the answer
here is the one the same script's own new Date(s) would give. Because a
stored cookie reads back with expires as a Date (above), and set now
takes a Date, a cookie can be read and written back with nothing to convert.
Anything else is refused with an error rather than guessed at: a non-string
value, a secure: "yes", a field carrying a tab or newline (the separators
of the format the jar stores), or a URL that cannot be parsed. expires has
its own refusals, loud ones: an Invalid Date, a string Date.parse cannot
read, a fractional number of seconds - date.getTime() / 1000 without the
floor, which the message names both cures for (pass the Date itself, or keep
the Math.floor) - a value before the epoch, and one further in the future than
the jar can store. A cookie stored under the
wrong domain reads as "the session did not stick" three requests later, which
is a much worse afternoon than a thrown error.
A written cookie is matched by the same rules a received one is. Setting it
for one host does not send it to another, /admin does not reach /, and the
jar's per-environment isolation holds - the write half is not a way around the
matching the read half respects.
When the write takes effect. A write is staged, not applied where it is made, and the next transfer of that execution carries it:
- A
setin a pre-request script rides the request it was made before, and that request's own cookie capture is what writes it into the jar. It cannot be discarded by that capture, which is the reason for the ordering: the engine replaces a scope's contents with what the finishing transfer held, so a write dropped into the jar beside an in-flight request would vanish with it. - A
setfollowed bypm.sendRequestis carried by that auxiliary request. Asetinside asendRequestcallback is a sequential write -pm.sendRequestis synchronous, so the callback runs after its transfer finished and the write lands on the next one. - A
setin a post-request script has no transfer left to ride, so it is applied to the jar when the script ends.
unset and clear are staged by the same rule and on the same queue - the
bullets say set because it is the one that reads as immediate, not because
the other two are. So jar.clear(url) followed by jar.getAll(url) in one
script sees the clear, because a read is answered over the staged writes as
well as the jar; the stored jar itself is not emptied until that execution's
next transfer carries the write.
clear has two forms. clear(url) is Postman's: it removes every cookie a
request to that URL would have carried - unset with no name to narrow it, and
the same matching, so a cookie stored for another host or under /admin when
you cleared / is left alone. clear() with no URL empties this
environment's jar and no other. Nothing is on disk either way, so the cost is
a re-login; other environments, and the no-environment jar, are untouched.
There is no confirmation gate for scripts - "reset my session" is a legitimate
thing for a script to want, and Settings → General → Cookies shows the result.
A URL the engine cannot parse is refused rather than cleared as a wipe that
happens to match nothing: clear is destructive, so "cleared no cookies" and
"that was not a URL" must not read the same to the script. Pass no argument at
all for the whole-jar form.
Load runs have no jar for writes; jar() and every method on it throw there
under every load-run script, including a scenario's inline ones - only the
flat reads above have the one per-user exception described above.
Flow control (pm.execution)¶
Inside a collection run (POST /runs with a scenario block), a script can say
where the sequence goes next:
pm.execution.setNextRequest('Checkout'); // run that request next, after this one finishes
pm.execution.setNextRequest(pm.info.requestId); // the same jump, by id
pm.execution.setNextRequest(null); // end this iteration; the next one still runs
pm.execution.setNextRequest('null'); // the quoted form Postman reads the same way
pm.execution.skipRequest(); // pre-request only: do not send this request
The script records an intent; the runner acts on it. Neither method reaches
into the run - they set a value on the script's result, and the runner, the only
thing that knows what a sequence is, reads it once the step has finished. So
setNextRequest does not abort the current request: it completes, its tests run,
and the jump happens afterwards. The last call in a script wins, across the
pre-request and test scripts alike.
setNextRequest takes a request's name or its id - the one a script
reads off pm.info.requestId - never its URL, and jumping backwards is allowed:
that is how a retry loop is written. A target is resolved against the names
first and the ids second, so a request whose name happens to be another
request's id sends the jump to the request you can see in the sidebar.
The string 'null' is the stop form, exactly as the real null above is -
Postman's runner reads it that way, and the quoted spelling is common in
collections written against it. The one exception is a run that carries a
request actually named null: a name the run carries is the more specific
answer and wins, so that request stays reachable.
Where it throws¶
Every one of these is a thrown error naming the reason, not a call that is
accepted and quietly dropped. A binding that cannot fail is worse than a missing
one: setNextRequest('checkout') ignored in a single send is a script that
reports success for something that never happened.
| Call | Where | What happens |
|---|---|---|
| Either method | A single Send (POST /execute) |
Throws - there is no next request |
| Either method | A load run's deferred tests script, and a scenario load run's deferred per-step script |
Throws - the script runs after the run finished, against a recorded response, and cannot redirect a sequence that already happened |
skipRequest() |
A test script inside a collection run | Throws - the request has already gone out; there is nothing left to skip |
setNextRequest() |
Anywhere | TypeError - the argument is required. Omitting it is not a synonym for null |
setNextRequest(3), setNextRequest('') |
Anywhere | TypeError - a target is a non-empty string, or null |
Where the step fails instead¶
Two cases are the runner's to refuse, because only it can see the plan. Both end
the iteration with the step marked errored and the reason in its row:
- A target no request in the run answers to, by name or by id. The message names the target and says both were searched.
- A name two or more requests share. The message names every step that answers to it, so the fix - rename one - is obvious. Resolving to the first match would run a sequence nobody asked for.
The cycle bound¶
setNextRequest makes an infinite loop a two-line script, so an iteration has a
ceiling: maxStepsPerIteration (config, limits). Its default of
0 derives the bound from the collection - ten times its request count, and
never fewer than 100 - so a straight-through iteration can never trip it, and a
legitimate retry loop in a short collection has room. Exceeding it fails that
step with a message naming the steps that were looping; the run continues with
the next iteration and still reaches a terminal status.
Skipped is never passed¶
A skipped step is stored and reported as skipped - its own count in the run
summary, its own outcome on the step's results row and on the SSE step event.
Its row carries the request it would have sent and no response, because there
was none; the app's step list shows that rather than an empty 200.
Custom metrics (pm.metrics)¶
A script can record a named value beside the run's built-in phases (issue
1500) - the scripted counterpart of the metric.record element¶
(elements.md):
pm.metrics.trend('ttfb', pm.response.responseTime); // one distribution sample
pm.metrics.counter('bytesOut', pm.response.size().total); // added to a running total
pm.metrics.counter('hits'); // increment defaults to 1
pm.metrics.rate('cacheHit', pm.response.headers.get('X-Cache') === 'HIT'); // one true/false sample
trend reports as a distribution (count/p50/p95/p99/max in the report's
customMetrics.<name>); counter is a running total, added to on every call; rate is the
percentage of calls whose value was true. All three share the same 32-distinct-name cap the
metric.record element does (api-reference.md's customMetrics section), across every name
either surface records under - a script that introduces a 33rd name past what the collection's own
metric.record elements already declared has that call silently do nothing, since the cap is
enforced loudly only where it can be, at POST /collections / POST /requests validate time for a
declared metric.record name.
Where it throws¶
The same false-success reasoning pm.execution documents: a call accepted and quietly dropped is
worse than one that says why it cannot run.
| Where | What happens |
|---|---|
A single Send (POST /execute) |
Throws - there is no run to record into |
A load run's deferred tests script, and a scenario load run's deferred per-step script |
Throws - a replayed sample is not the run itself |
trend(name, value) / rate(name, value) with fewer than two arguments, or an empty name |
TypeError |
counter(name) with an empty name |
TypeError |
Everywhere else - the sequential run, a scenario load run's inline step.before/step.after hooks,
and design mode's script.pre/script.post - all three methods record into that run's own
collector, the same MetricsCollector::record_custom_metric the metric.record element writes
through.
Data rows (pm.iterationData)¶
A run can be given a set of rows - a CSV, TSV, JSON or JSONL file the app parses
and sends inline on the run payload (the file
format). A collection run states them as
scenario.data and a single-request load run as the top-level data (issue
993). Row i % rows binds to iteration i - for a single request an¶
iteration is one submission - and that row is what pm.iterationData reads:
pm.iterationData.get('username'); // this iteration's value for that column
pm.iterationData.has('coupon'); // whether the row carries that column
pm.iterationData.toObject(); // the whole row as a plain object
get on a column the row does not carry returns undefined, as every other
pm scope reader does. Values keep their JSON type: a JSON file's 3 arrives
as a number, and a CSV column arrives as a string, because that is what the
file said.
The row reaches a request through a different channel, with a different type
story. pm.iterationData hands a script the cell as the value it is - a
number stays a number, a null stays null, and a branch can read either.
{{data.column}} (see
api-reference.md) hands
the request the cell as text, because a URL, a header and a body are text;
its type only survives where the surrounding document has types of its own,
which is why placement inside or outside a JSON string literal is what decides
whether {{data.n}} arrives as 2 or "2". The two also disagree about
null on purpose: a script may branch on it, while a token that substituted it
would write nothing where a value belonged, so the bind errors instead. An
optional column belongs on this side of the line.
has answers presence, the same way pm.environment.has and its siblings do.
A column whose value is null is true - the row carries it - which is the
fact get alone cannot state without the reader knowing that an absent column
comes back as undefined while a null one comes back as null.
A single send can bind one row too - POST /execute takes an optional
data object (one row, not the array a run sends), which is what the request
builder's Send with row does and what MCP's run_request exposes as data.
Both scripts then read it as pm.iterationData, and pm.info.iteration is 0
with pm.info.iterationCount 1 - the send is row 0 of 1. This is how a
script that reads a row gets an edit loop that is not "start a run, find the
step, read the result"; the row binds {{data.column}} in the request as well.
See api-reference.md.
pm.iterationData is undefined where there is no row - an ordinary Send,
and any run started without a data set. Where a run was given rows, its
deferred script reads one whichever shape the run took: a sampled response
carries the row the submission or iteration that produced it was bound to, so
the row is a fact about that response rather than a guess. (pm.info.iteration and
pm.info.vu travel the same way and are populated on both shapes since issue
994 - what a sample carries is the identity its submission claimed before it¶
was sent, never its position in a reservoir.) That is deliberate, and it is the opposite treatment to pm.execution
above: flow control is a capability, and one that silently does nothing is a
false success, so it is always bound and explains itself. A data row is data,
and "this run is not data-driven" is a fact a script may legitimately branch on:
A stashed reference (globalThis.saved = pm.iterationData) read from a later
script throws rather than answering with the finished run's row.
To put the row into the request itself, use {{data.column}} - or, while a
row is bound, the bare column name - instead. A script reads
pm.iterationData after its step's request was composed, so it cannot
change where the request goes without editing pm.request by hand. The
reserved data.* namespace does that directly: a URL, header, body, form field
or credential carrying {{data.email}} has it substituted with the
iteration's row immediately before the send. It is a namespace, not a variable
scope - it cannot be read or written through pm.variables - and it is
documented under
Scenario runs. A bare {{email}} binds the
same way while a row is bound (issue #1007), and unlike the prefixed
spelling it is readable through pm.variables.get("email") too - see
pm.variables above.
The substitution is written for the place it lands in: a token inside a JSON or XML body is escaped for that document, and a cell carrying a line break bound into a header - a header name, a header value, or a credential written into a header line - is refused rather than allowed to end the header line and forge one of its own.
It is read-only¶
set, unset and clear are bound and throw. The rows are an input to the
run, not a variable scope: there is no destination a write could land in, the
next iteration binds a different row regardless, and accepting the value would
report success for something that vanished. Carry a value forward with
pm.environment, pm.collectionVariables or pm.globals.
How many iterations, and which row¶
With data present and iterations absent, the run performs one iteration per
row (Postman's default). With both given the explicit count wins and the row
index wraps - five iterations over three rows read rows 0, 1, 2, 0, 1.
The wrap is not silent: every step's results row and every step SSE event
carries dataRowIndex, and the app's step list shows it beside the iteration
("Iteration 4 · Row 1"). pm.info.iteration reports the pass; dataRowIndex
reports the row, and with a wrap the two deliberately disagree.
Rows are validated before the run row exists: a data that is present and empty,
a row that is not an object (the message names its index), or a set over
maxScenarioDataRows is a 400. Rows are never persisted - the run's snapshot
records dataRowCount and nothing else.
Console Output¶
Log messages that appear in test results:
console.log('Response:', pm.response.json());
console.info('Info message');
console.warn('Warning message');
console.error('Error message');
Objects and arrays are pretty-printed with JSON.stringify(value, null, 2)
rather than [object Object]; a value that cannot be serialized (a circular
reference, say) becomes [Object: unserializable].
The level travels with the line. Each entry on the wire is
{ "source": "pre" | "test", "level": "log" | "info" | "warn" | "error",
"message": "..." } - see
consoleLogs in the API reference. The four
methods are bound to one C function distinguished by QuickJS's magic
argument (setup_console), so the level is captured at the call; it cannot be
recovered from the text afterwards, which is why it used to be lost. The app's
Console tab draws warn and error in their status tokens and labels every
non-log line in a gutter.
Examples¶
Validate JSON Response¶
pm.test('User has correct fields', function() {
const json = pm.response.json();
pm.expect(json).to.have.property('id');
pm.expect(json.name).to.be.a('string');
pm.expect(json.email).to.include('@');
});
Check Status Codes¶
Set Variables from Response¶
// Extract token from response and save to environment
const json = pm.response.json();
pm.environment.set('userId', json.id);
pm.environment.set('token', json.token);
Pre-request Script¶
Modify request before sending:
// Add timestamp and correlation headers
pm.request.headers['X-Timestamp'] = Date.now().toString();
pm.request.headers['X-Request-Id'] = 'req-' + Math.random().toString(36).slice(2, 10);
Both headers are on the request that is actually sent - see Mutating the request for the rules and Worked examples for harder cases.
Response Time Assertion¶
pm.test('Response time is acceptable', function() {
pm.expect(pm.response.responseTime).to.be.below(1000);
});
Array Validation¶
pm.test('Returns array of users', function() {
const users = pm.response.json();
pm.expect(users).to.be.an('array');
pm.expect(users.length).to.be.above(0);
pm.expect(users[0]).to.have.property('id');
});
Header Validation¶
pm.test('Has Content-Type header', function() {
pm.response.to.have.header('Content-Type');
pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json');
});
Worked examples: rewriting a request¶
Everything below runs in a pre-request script and changes what goes on the wire. The rules these rely on are in Mutating the request; read the sandbox note first if you are here to sign a request, because what is missing decides the shape of most of these.
Rewrite a JSON body, then fix the headers that describe it¶
The body is a string in and a string out, so a structural edit is parse - mutate - stringify. Anything derived from the body (a length, a digest, a checksum) has to be computed after the edit, or it describes the old one.
var body = JSON.parse(pm.request.body.raw);
body.metadata = { client: 'vayu', sentAt: new Date().toISOString() };
delete body.debugOnly;
pm.request.body.raw = JSON.stringify(body);
// Recomputed from the final body, not the original.
pm.request.headers['Content-Length'] = String(pm.request.body.length);
Content-Length is illustrative - libcurl sets it from the body it is given, so
you do not need to. Any header you derive yourself works the same way.
Add or replace a query parameter¶
pm.request.url.query reads the parameters; the write is the whole URL, so
rebuild the query string and assign it back.
function withQueryParam(url, name, value) {
var pair = { key: encodeURIComponent(name), value: encodeURIComponent(value) };
var kept = url.query.all().filter(function (p) { return p.key !== pair.key; });
kept.push(pair);
var query = kept
.map(function (p) { return p.value === null ? p.key : p.key + '=' + p.value; })
.join('&');
return url.protocol + '://' + url.getHost() + (url.port ? ':' + url.port : '') +
url.getPath() + (query ? '?' + query : '') + (url.hash ? '#' + url.hash : '');
}
pm.request.url = withQueryParam(pm.request.url, 'traceId', 'run-' + Date.now());
Switch method and body together¶
Changing the verb and the payload in one script is fine - the write-back applies all of it or none of it, so the request never goes out as a POST that still carries the GET's shape.
if (pm.environment.get('mode') === 'bulk') {
pm.request.method = 'POST';
pm.request.url = pm.request.url.replace('/items/1', '/items/bulk');
pm.request.body = JSON.stringify({ ids: [1, 2, 3] });
pm.request.headers['Content-Type'] = 'application/json';
}
Two edges worth knowing. A HEAD request that carries a body is refused by
the send path with a clear error rather than silently stripped, so do not switch
to HEAD without also delete pm.request.body. And a body set on a request that
had none is sent as raw text - Vayu does not infer a Content-Type, so set it
yourself as above.
Replace engine-applied auth with a custom scheme¶
Auth is resolved into the request before the script runs, so the script sees the real header and has the last word. Removing and re-adding is how you swap schemes rather than stack them.
delete pm.request.headers['Authorization'];
pm.request.headers['X-Api-Key'] = pm.environment.get('apiKey');
Use the exact name, capitals included. pm.request.headers is a plain JS
object, and JS property names are case-sensitive, so
delete pm.request.headers['authorization'] deletes nothing and the header
survives - even though HTTP itself treats the two as one name. The engine
applies auth as Authorization. When in doubt, look before you delete:
Object.keys(pm.request.headers).forEach(function (name) {
if (name.toLowerCase() === 'authorization') delete pm.request.headers[name];
});
For the same reason, do not leave two names that differ only in case -
setting authorization while Authorization is still there is two JS
properties but one HTTP header, so the write-back rejects it rather than
picking a winner, and the request is sent unchanged with the reason in
preScriptError.
Sign a request¶
pm.crypto gives a pre-request script a real HMAC, so the request it rewrites
can be signed for what it actually became:
var timestamp = Date.now().toString();
// The sorted-query canonicalization every HMAC scheme wants. `all()` is the
// view that keeps duplicates and wire-order values, so what is signed is what
// was sent.
var sortedQuery = pm.request.url.query.all()
.map(function (p) { return p.key + '=' + (p.value === null ? '' : p.value); })
.sort()
.join('&');
var canonical = [
pm.request.method,
pm.request.url.getPath(),
sortedQuery,
timestamp,
pm.request.body || ''
].join('\n');
pm.request.headers['X-Timestamp'] = timestamp;
pm.request.headers['X-Signature'] =
pm.crypto.hmacSha256(pm.environment.get('secret'), canonical);
Note the ordering: the canonical string is built from pm.request after the
other edits, so it covers what is actually sent. If you sign first and rewrite
the body second, the signature describes a request that never existed.
Signatures compared as base64 - Shopify's, for instance - only need a different last argument:
pm.request.headers['X-Signature'] =
pm.crypto.hmacSha256(pm.environment.get('secret'), canonical, 'base64');
Derived signing keys (AWS SigV4)¶
A scheme that chains HMACs cannot be expressed with text output alone: each
round is keyed by the raw digest of the previous one, and hex or base64 is a
different byte string. That is what the 'bytes' encoding is for - it returns a
Uint8Array, which is also accepted as a key or as data:
var secret = pm.environment.get('aws_secret_key');
var date = '20150830';
var kDate = pm.crypto.hmacSha256('AWS4' + secret, date, 'bytes');
var kRegion = pm.crypto.hmacSha256(kDate, 'us-east-1', 'bytes');
var kService = pm.crypto.hmacSha256(kRegion, 'iam', 'bytes');
var kSigning = pm.crypto.hmacSha256(kService, 'aws4_request', 'bytes');
// stringToSign is built per the SigV4 spec, hashing the canonical request with
// pm.crypto.sha256(canonicalRequest) - hex, which is what the spec asks for.
pm.request.headers['Authorization'] =
'AWS4-HMAC-SHA256 Credential=…, Signature=' +
pm.crypto.hmacSha256(kSigning, stringToSign);
What is hashed¶
A string contributes its UTF-8 bytes - the same bytes the engine puts on the
wire, so a digest computed here matches one computed by sha256sum over the
same text. A Uint8Array contributes its bytes unchanged; that is the only way
to key an HMAC with bytes that are not valid UTF-8. Passing anything else (an
object, a number, null) throws rather than being stringified: hashing the text
[object Object] would return a digest that looks perfectly valid.
Hand a value to the test script¶
A pre-request script and its test script share the variable scopes, not their local state. Stash what the assertion needs:
// Pre-request
var nonce = 'n-' + Math.random().toString(36).slice(2);
pm.request.headers['X-Nonce'] = nonce;
pm.environment.set('lastNonce', nonce);
// Tests
pm.test('server echoed our nonce', function () {
pm.expect(pm.response.headers['x-nonce']).to.equal(pm.environment.get('lastNonce'));
});
What a script can compute¶
The sandbox is QuickJS plus pm, console, and the two base64 globals.
Available: JSON, Date, Math, RegExp, String, Array, Object,
Number, Uint8Array, Promise, BigInt, encodeURIComponent, parseInt
and the rest of the ES2020 built-ins, plus:
| Name | Shape |
|---|---|
pm.crypto.sha256(data, encoding?) |
SHA-256; data is a string (UTF-8) or Uint8Array |
pm.crypto.hmacSha256(key, data, encoding?) |
HMAC-SHA256; key and data take the same types |
pm.sendRequest(urlOrOptions, callback) |
Send an auxiliary request, synchronously - see above |
btoa(binaryString) |
base64-encode one byte per code unit |
atob(base64) |
decode to a binary string; throws on invalid base64 |
encoding is 'hex' (the default), 'base64', 'base64url' or 'bytes';
'bytes' returns a Uint8Array, and any other value throws rather than
silently falling back to hex.
Not available: crypto / crypto.subtle, TextEncoder, URL,
URLSearchParams, setTimeout, require, fetch. The practical
consequences: no general URL constructor - the request's own URL is already
parsed for you, see URL parts - no hash
other than SHA-256 (no MD5, no SHA-1, nothing asymmetric), and nothing
asynchronous. There is no fetch, but there is
pm.sendRequest, which is
synchronous and bounded rather than a Promise-returning stand-in.
Why the hashing surface is not called crypto. Web Crypto's crypto.subtle
is Promise-based. Promise exists here, but nothing drains the job queue - there
is no event loop and no setTimeout - so an await crypto.subtle.digest(...)
would never resume and the script would report a timeout rather than a result.
Vayu therefore takes a name of its own and is honestly synchronous. btoa and
atob keep their standard names because they are synchronous on the web too,
and they keep the rest of their semantics with them: they operate on binary
strings, one byte per code unit, so btoa throws on a code point above U+00FF
rather than silently UTF-8 encoding it.
Both halves of this list are pinned by tests in
engine/tests/script_engine_test.cpp, so if a global is ever added or removed
this section is what needs rewriting.
What the editor offers, and where it comes from¶
Everything the script editor advertises - completions, hover documentation and
the insertable templates listed under the editor - is generated by the engine
and served at GET /scripting/completions
(engine/src/http/routes/scripting.cpp). There is no second list in the app,
which is why a member documented here and offered there cannot drift apart.
An entry whose kind is 28 is a template rather than a member, and it
carries two fields no other entry does:
| Field | Values | What it decides |
|---|---|---|
context |
pre, test, both |
Which script kind the template belongs in. A template that reads pm.response is test, because a pre-request script has no response to read; one that writes pm.request is pre, because a test script's writes go nowhere. |
group |
Variables, Request, Response, Tests, Signing, Logging |
The heading it is listed under. |
The app's snippets surface under each script editor is built from those two:
it shows the templates for the editor it sits under, grouped by heading, and
inserts one at the cursor with its ${1:placeholders} intact. Adding a
template to the table is therefore all it takes to offer it in the app - see
pm-api-compatibility
for the payload itself.
Limitations¶
The language is current; what is missing is the host environment:
- Modern syntax works. Optional chaining (
?.) and nullish coalescing (??) are ES2020 and supported, as are later additions likeArray.prototype.at,Object.hasOwnandString.prototype.replaceAll. This section used to say the opposite, listing?.and??as "ES2021+" and unavailable - wrong on both counts, so any workaround written around that claim can go. What limits a script is the global list above, not the syntax. - No host globals: no
setTimeout,fetch,URL,TextEncoder,structuredCloneorcrypto.subtle- see the table above for what replaces the ones that have a replacement. - No Node.js APIs: No
require(),fs,http, etc. - Sandboxed: No filesystem access. The only network access is
pm.sendRequest- capped at 10 requests per script, bounded by the script's own deadline and by the response byte bound its path reads, and refused outright for agent-started runs. - Memory limit: 64MB per script execution
- Timeout: 5 seconds per script (default), enforced by a wall-clock deadline - an
infinite-loop script is aborted and reported as an error rather than hanging the
engine. Configurable via the
scriptTimeoutsetting (milliseconds);0disables the limit. The deadline is checked between bytecode operations, so it cannot interrupt a blocking call - which is whypm.sendRequestclamps its own timeout to the budget that is left rather than relying on it. A script is reported as timed out when the deadline actually stopped it, not whenever its error happens to land past the deadline: a script that throws its own error at the buzzer reports that error, andpm.sendRequestrefusing a spent budget reports its own sentence rather than a generic timeout line. A function an assertion calls -pm.expect(fn).to.throw(),.to.satisfy(fn)- is stopped by the same deadline, and that is reported as the abort it is, never as a satisfied assertion: the engine stoppedfn,fndid not throw. Inside apm.testthat is a failed test, sincepm.testreports what its callback threw; outside one it ends the script. A later assertion in the same script is still judged on its own, and a stack overflow inside such a function is aRangeErrorthe script could have caught, so that still counts as a throw.
Script Execution Context¶
Pre-request Scripts¶
- Execute before sending the HTTP request
- Can modify
pm.request- method, url, headers and body - and the edits are applied to the request that is sent (rules) - Can access
pm.environment,pm.collectionVariablesandpm.globals - Cannot access
pm.response(request hasn't been sent yet) pm.info.eventNameis"prerequest"here- Run in Design Mode / Send only, not in load tests. Since issue #1503 this is
no longer silent: a load run reports each affected step as
preRequestScript: "skipped"in itsscenario.stepsbreakdown, and the run'swarningsarray carries one line naming how many steps did (see api-reference.md).
Test Scripts (Post-request)¶
- Execute after receiving the HTTP response
- Can access
pm.request(read-only here - it has already been sent) andpm.response - Can access
pm.environment,pm.collectionVariablesandpm.globals pm.info.eventNameis"test"here- Test results are included in the response
Load Test Scripts¶
- Test scripts in load tests are executed deferred (after test completion)
- Only a sample of responses are validated: 1 in
response_sample_ratecompletions, retained up tomax_response_samples. The retained set is a uniform sample of the whole run, not its opening - past the bound a later response displaces a uniformly chosen incumbent, so a target that starts failing halfway through is graded on those failures rather than on the healthy window before them - A second bound applies to the same store:
max_response_sample_bytes(maxResponseSampleBytes, 256 MiB by default) is the whole-run budget for the retained bodies, because each is kept whole and a target answering 1 MiB responses would otherwise put ~1 GB in that store. Past it a sample is dropped entire rather than truncated - a script reading a cut body would fail a response the target got right - and the drop is counted the same way, insampling.responseSamplesDropped - A run that spends that budget is graded on the part of it that fit, not on
a uniform sample: the count cap displaces incumbents and stays uniform, while
an exhausted byte budget simply stops admitting. Only a target whose retained
bodies average more than ~256 KiB reaches it at the defaults; raise
maxResponseSampleBytes, or lowermax_response_samplesso fewer, later responses share the budget, if that matters for the run you are grading. The report says which one happened:sampling.responseSampleBudgetSpent samplesTestedin the report (TestsSampled) is the size of that sample, not the run's request count, andsampling.responseSamplesDroppedbeside it says how many responses the bound thinned away- Results are aggregated and reported in the final report:
testsPassed/testsFailedtally everypm.testcall across every sampled response, so a script whose assertions are a mix of passing and failing reports both counts and names each failing assertion (name: message) rather than collapsing the sample to one opaque failure. Only a script that threw before anypm.testran reports a single script-level failure, carrying the thrown message (issue #1502) pm.inforeports the same identity a Send does:eventNameis"test", andrequestId/requestNameare the run's linked request when it has one. It also reportsiteration- the index the sampled submission claimed before it was sent - andvu, which is1here because a single request repeated is one user's iterations (issue #994; seepm.info)POST /runs'stestsfield carries the collection chain's test scripts as well as the request's own, joined into one script the way it always has been (see Script elements below) - a collection-level assertion is now checked under load, not only in design mode- A value a pre-request script would have set - a token fetched and written
with
pm.environment.set- never reaches a later step or request under load, because the script that would set it never runs. The{{token}}it was meant to fill goes on the wire literally instead of being refused, and since issue #1503 the run counts it:unresolvedTokensper step, and one line in the run'swarningsarray naming a few of the affected variables (see api-reference.md)
All three variable scopes are readable, and none of them is written back. A
deferred replay reads the run's own environment (the environmentId the run was
started with), the globals, and the collection chain - the leaf plus its
ancestors, exactly as a Send does, so an inherited name answers the same in both
modes. A scenario load run's collection scope is the collection being run; a
single-request run's is the collection of its linked request. Earlier engines
bound an empty environment and no other scope at all, so
pm.environment.get('region') read undefined under load and the same test
gave opposite verdicts on Send and under load.
Writes are the deliberate exception. A set(), unset() or clear() in a
deferred script is visible to the samples replayed after it - one set of scopes
serves the whole pass - but nothing is persisted: only design mode writes
variables back. A sampled response is not an iteration, so there is no ordering
under which "whichever replay ran last wins" would be a defensible thing to
store. If a load run's scripts must leave a value behind, write it in design
mode instead.
A scenario load run validates per step instead. It has no run-level tests
field: each plan step carries its own post-request script, and after the run
drains each is replayed against the responses that step produced. The tallies
land on scenario.steps[].tests in the report - a whole-run pass/fail count
over a sequence says something failed, not which step - while the aggregate
still appears as testValidation. Three differences from the single-request
shape:
pm.requestis the step's own request, andpm.info.requestId/requestNameare that step's, not a run-level one.pm.info.iterationis the virtual user's own iteration index andpm.info.vuis that user's number, so a script can tell two users' responses apart - beside thepm.iterationDataa single-request run's rows also provide, so a script asserting on{{data.*}}-driven behaviour grades the right row either way.- The sample budget is split across the steps that carry a script rather than spent run-wide, so the last step of a long plan is validated instead of being crowded out by the first. A step with no script is never sampled.
pm.execution still throws throughout - a deferred script cannot redirect a
sequence that already happened - and no step script runs inline.
Script elements (design send and the sequential run)¶
Issue #1514 cut POST /execute and the stored resources (POST/PUT
/collections, POST/PUT /requests) over to elements for good - a clean
cut, not a transitional alias. preRequestScript, preRequestScripts,
postRequestScript, postRequestScripts and tests are all refused with a
400 naming elements as the replacement (null is accepted as a no-op; a
real value is not). A script is now a script.pre or script.post element
- {"kind": "script.pre" | "script.post", "config": {"script": "..."}} -
compiled and run by vayu::core::ElementPipeline at step.before /
step.after alongside every other element kind. See
elements.md.
Each script element runs its own script, independently - there is no more
joined-parts scope. A design send's or a sequential step's compiled list
can carry more than one script.pre element (one folded from the collection,
one from the request, say), and the pipeline calls each one's apply in
list order, but every call opens a fresh ScriptContext - there is no
longer a blank-line join that ran the collection's and the request's text as
one script in one shared scope. A const or let one script.pre element
declares is not visible to the next one. State that is meant to carry
across them has to go through a store the elements share instead:
pm.environment / pm.collectionVariables / pm.globals / pm.iterationData
read back whatever an earlier element wrote, and a pm.request edit is
visible to a later script.pre element (and to the send itself) because
every element at a step shares the one Request object being sent - only the
JavaScript variable scope is per-element. The same holds for script.post
elements and pm.response.
**A single-request POST /runs runs the element pipeline too now (issue
1594), and no longer joins.** It calls refuse_legacy_script_fields the¶
same as POST /execute - tests / postRequestScripts / postRequestScript
/ preRequestScript(s) are all refused with a 400 naming elements - and
reads its own step-level elements off a new field, requestElements, a
distinct key from this endpoint's own elements (the run-level
timers/scripts override, below). Each script.pre / script.post
element there runs its own script independently, opening a fresh
ScriptContext per element, exactly like a design send's or a sequential
step's compiled list above - there is no blank-line join here either.
A scenario load run's elements run inline now (issue #1495), opt-in per
element. submit_one (the producer, one virtual user at a time) runs
step.before before binding and submitting; the completion runs step.after
once the response is in hand. extract.*, assert.* and metric.record always run there. A
script.pre / script.post element runs there only when its own
config.inline is true, or the run's elements.scripts override
("allInline" / "allDeferred", beside the default "asMarked") forces it
one way or the other - unmarked, a step's scripts still defer to the post-run
tests replay exactly as before this issue, and the replay itself now skips a
step whose script already ran inline rather than running it twice.
Every virtual user (VU) gets its own small write layer over the run's shared
variable scopes (ScopeOverlay, on the VU): what one VU's inline
extract.* or script.* writes on step 1 is visible to that VU's step 2,
and invisible to every other VU's concurrent step 2 - two VUs sharing one
name would otherwise read and overwrite each other's state, which is exactly
the correlation-under-load bug this issue exists to fix. pm.execution still
throws under load (in_scenario is false): an inline script can write
state and mutate the request, the same way script.pre always could, but it
cannot redirect the sequence - that stays the deferred replay's own script
and, eventually, a control.* element's job, neither of which runs inline.
The overlay is cleared at the same iteration boundary pm.cookies' per-VU
jar is, for the same reason: a new iteration is a new user.
A single-request load run's requestElements dispatch the same way, per
submission rather than per VU (issue #1594) - there is no persistent VU
object on this path to hold state across submissions. submit_one_request
runs step.before immediately before the transfer and step.after once the
response is in; extract.* / assert.* run there unconditionally, and a
script.pre / script.post element runs inline only when its own
config.inline is true or the run's elements.scripts override forces it,
RunContext::script_element_runs_inline deciding exactly as it does for a
scenario step. An unmarked script.post still defers - to this run's own
completion replay, not a scenario's - and an unmarked script.pre simply
never runs, because there is no pre-request replay to defer it to. Each
submission gets its own fresh ScopeOverlay, discarded after, so a
pm.environment.set one submission's inline script makes is visible to that
same submission's own residual-token pass and later steps, never to a
concurrent submission's. timer.think costs no new thread here either:
RunContext::reserve_think_wait folds its wait into the same
maintain_concurrency in-flight accounting every load strategy already
polls, rather than blocking a worker.
See elements.md for the full mechanics -
ScopeOverlay, the per-thread ScriptEngine, elements.includeScriptTime,
and what remains deliberately unwired on the single-request path
(control.* - a lone request has no sequence for a controller to jump or
gate).
Error Handling¶
Script errors are caught and reported:
// If script throws, error is captured
try {
const json = pm.response.json();
} catch (e) {
// Error is reported in test results
}
Test failures don't stop script execution - all tests run and results are collected.
Best Practices¶
- Use descriptive test names:
pm.test('Status code is 200', ...) - Validate structure before accessing: Check if JSON exists before accessing properties
- Use environment variables: Store sensitive data in environments, not scripts
- Keep scripts simple: Complex logic should be in your application code
- Log debugging info: Use
console.log()to debug script issues
API Reference¶
For complete API documentation, see the Scripting Completions API which lists all available pm.* functions and properties.