Skip to main content

Script API reference

Everything Snag adds on top of the Rhai standard language. Each test gets a fresh engine, so nothing here carries state between tests.

At a glance

GroupFunctions
Requestsget post put patch delete head · .header .bearer .body .json · .send
Responses.status .ok .text .duration_ms · .header(name) .json()
JSONfield(value, path) · parse_json(text)
Assertionsassert_status assert_ok assert_body_contains assert_faster_than assert_eq assert_contains assert fail
Authbasic(user, password)
Environmentenv env_or
Outputprint debug print_response
Timingsleep_ms
Lifecyclefn setup fn teardown · on_teardown(cb) on_teardown(cb, always)

Requests

get(url) · post(url) · put(url) · patch(url) · delete(url) · head(url)

Create a request builder for that method. Nothing is sent until .send().

let req = get(`${base_url}/users?page=2`);

.header(name, value)Request

Adds a header. Repeatable; nothing is replaced or de-duplicated.

get(url).header("accept", "application/json").header("x-request-id", "snag-1")

.bearer(token)Request

Shorthand for authorization: Bearer <token>.

get(`${base_url}/me`).bearer(env("API_TOKEN"))

.body(text)Request

Sets a raw string body. No content type is set for you.

post(`${base_url}/graphql`)
.header("content-type", "application/json")
.body(`{"query":"{ me { id } }"}`)

.json(value)Request

Serialises any Rhai value to JSON and sets content-type: application/json.

post(`${base_url}/carts`).json(#{
currency: "EUR",
items: [#{ sku: "A-1", qty: 2 }],
})

Fails the test if the value cannot be serialised.

.send()Response

Performs the request. This is the only call that touches the network. Failures — DNS, connection refused, TLS, client timeout — fail the test with request failed: <reqwest error>.

let res = get(`${base_url}/health`).send();

The client is per test, and per attempt when retrying: no connection pool, no cookie jar, and no shared state between tests. Redirects follow reqwest's defaults.

Responses

AccessorTypeNotes
res.statusintegerHTTP status code
res.okboolTrue for 200..=299
res.textstringBody as text, read in full at send() time
res.duration_msintegerWall-clock milliseconds for this request

res.header(name)string

Case-insensitive lookup. Returns "" when the header is absent — it never throws, so an assertion produces the error rather than the lookup.

assert_contains(res.header("content-type"), "application/json");

res.json() → value

Parses res.text into Rhai values: JSON objects become maps, arrays become arrays. Fails the test with response body is not valid JSON: … when the body is not JSON.

let body = res.json();
assert_eq(body.status, "ok");

Assert the status before decoding: a 502 with an HTML error page produces a much clearer failure that way.

JSON

field(value, path) → value

Walks a decoded value by a dotted path. Map keys and array indices use the same syntax; a numeric segment indexes an array.

field(body, "json.project") // map key
field(body, "items.0.sku") // second-level array index
field(body, "") // the value itself

Every failure names the segment that failed:

SituationMessage
Missing map keyno key `nope` in path `a.nope`
Non-numeric segment on an array`x` in path `items.x` is not an array index
Index past the endindex 3 out of range in path `items.3`
Descent into a scalarcannot descend into `x`: value is not an object or array

The paths and keys are quoted with backticks in the real output, which is what to grep a CI log for.

That is the reason to prefer field() over direct indexing for anything more than one level deep.

parse_json(text) → value

Rhai's own JSON parser, for a string you built or received outside a response body.

let expected = parse_json(`{"currency":"EUR"}`);
assert_eq(field(expected, "currency"), "EUR");

Assertions

Every assertion either does nothing or fails the test with a message beginning assertion failed:.

assert_status(res, expected)

Exact status match. On failure, includes up to 500 characters of the body.

assert_status(res, 201);
assertion failed: expected status 201, got 500
body: {"error":"cart service unavailable"}

assert_ok(res)

Passes for any 200..=299. Same body-in-message behaviour.

assert_body_contains(res, needle)

Substring match against the raw body text, before any JSON decoding.

assert_body_contains(res, "Example Domain");

assert_faster_than(res, max_ms)

Fails when res.duration_ms > max_ms.

assert_faster_than(res, 800);
assertion failed: request took 1204ms, budget was 800ms

This measures one request, not the whole test.

assert_eq(a, b)

Registered once per scalar type: integer, float, bool, and string. There is no map or array overload, and no implicit conversion between integer and float — Rhai has no generics, so each overload is explicit.

assert_eq(field(body, "total"), 3);
assert_eq(field(body, "ratio"), 0.5);
assert_eq(field(body, "enabled"), true);
assert_eq(field(body, "name"), "snag");
assertion failed: expected 2, got 1

The message reads expected b, got a — the second argument is the expected value.

assert_contains(haystack, needle)

String containment, for values you already extracted.

assert_contains(res.header("content-type"), "json");

assert(condition, message)

Arbitrary condition with your own message.

assert(base_url.starts_with("https://"), "base_url must be https");

fail(message)

Fails unconditionally. Useful in a branch that should be unreachable.

if field(body, "state") == "error" {
fail(`job failed: ${field(body, "reason")}`);
}

Auth helpers

basic(username, password)string

Returns a complete HTTP Basic header value: Basic followed by the base64 of username:password.

let res = get(`${base_url}/me`)
.header("authorization", basic("demo", env("API_PASSWORD")))
.send();
basic("u", "p") // "Basic dTpw"

It returns the value, not a builder — pass it to .header(...).

Environment

env(name)string

Reads a process environment variable. Fails the test when it is unset:

environment variable `API_TOKEN` is not set

The right choice for credentials — a missing token should fail loudly rather than send an unauthenticated request.

env_or(name, fallback)string

Same lookup, returning fallback when the variable is unset.

let stage = env_or("SNAG_STAGE", "local");

Output

All output functions write to a per-test buffer, never to stdout, so parallel tests cannot interleave. See reporters for where it surfaces.

print(value)

print(`served in ${res.duration_ms}ms`);

debug(value)

Rhai's debug output, captured with its source position:

[debug line 3, position 1] "cart-42"

Dumps status and duration, every response header, and the first 40 lines of the body:

200 (142ms)
content-type: application/json
content-length: 33
{"project":"snag","ok":true}

Handy while writing a test; think twice before committing it, since response headers and bodies can carry secrets into a published CI artifact.

Timing

sleep_ms(milliseconds)

Blocks this test's worker. Negative values are treated as zero.

sleep_ms(500);

With -j 1 this stalls the entire run; with the default pool it stalls one worker. Always pair polling loops with a bounded iteration count.

Lifecycle

Hooks that run around the script's top-level body. Manifest-declared setup and teardown scripts wrap these — see Setup and teardown.

fn setup()

If the script defines a zero-argument setup, Snag calls it before the body. Variables it declares stay in scope for the body:

fn setup() {
let res = post(`${base_url}/carts`).send();
assert_status(res, 201);
let cart_id = field(res.json(), "id");
}

let res = get(`${base_url}/carts/${cart_id}`).send();
assert_ok(res);

A throw in setup fails the test as setup: <error> and the body does not run.

fn teardown()

Called after the body, whether the test passed, failed, or timed out.

fn teardown() {
delete(`${base_url}/carts/${cart_id}`).send();
}

on_teardown(callback) · on_teardown(callback, always)

Registers cleanup from inside the script, closing over the variables it needs. Callbacks run last registered first, before fn teardown().

let res = post(`${base_url}/users`).json(#{ name: "temp" }).send();
let user_id = field(res.json(), "id");
on_teardown(|| delete(`${base_url}/users/${user_id}`).send());

always defaults to true. Pass false to skip the callback when the test failed:

on_teardown(|| print("only after a green run"), false);

Callbacks registered by a manifest setup script work the same way — that is how a shared fixture script cleans up after itself.

Teardown gets a fresh timeout budget, so cleanup still runs after a test times out. If teardown throws while the test itself passed, the test fails with teardown: <error>; if the test had already failed, its own error is kept and the cleanup error is added to the captured output.

Rhai essentials

The parts of the base language tests actually use:

let x = 41; // variable
const LIMIT = 500; // constant
let s = `total: ${x + 1}`; // template string
let map = #{ a: 1, b: "two" }; // object map
let list = [1, 2, 3]; // array

map.a // 1
list[0] // 1
list.len() // 3

if x > 40 { print("big"); } else { print("small"); }
for item in list { print(item); }
for i in 0..10 { } // range, exclusive of 10
while cond { }
break; continue;

fn double(n) { n * 2 } // function, last expression is the result

"42".parse_int() // 42
"4.5".parse_float() // 4.5
"abc".to_upper() // "ABC"
"a,b".split(",") // ["a", "b"]
"abc".starts_with("a") // true
type_of(map) // "map"

There is no null; the empty value is (). Full documentation: rhai.rs/book.

Scope

Before a script runs, every variable resolved for the test — suite variables merged with test variables — is pushed into the scope as a constant:

[variables]
base_url = "https://api.example.com"
let res = get(`${base_url}/health`).send(); // just an identifier

Assigning to one is a runtime error (Cannot modify constant base_url). Copy it into a let first if you need a mutable version.

What is not available

Rhai is used with its standard package, and Snag adds only the functions above. There is no filesystem access, no process spawning, no socket API beyond the HTTP builders, no cookie jar, no WebSocket support, and no shared state between tests. Setup and teardown scripts share a scope with the test that pulled them in, but nothing crosses from one test to another.