Steps
The script step
A script step runs a program you wrote, in your own Node or Python, and reads back what it
reports over vero.script/v1. It is the step for a check a plan cannot
say: a loop, a client library, a browser. Everything a plan can say stays in http, sql and exec
steps, where vero sees and times each request. Script steps is the guide
to writing one; this page is what vero does with it.
| Key | Type | Meaning |
|---|---|---|
runtime |
node or python |
found on PATH at load (python3, then python) |
entry |
string | the script, relative to the plan file; must exist |
input |
map | templated; the only thing from the run the child sees |
timeout |
duration | alternative to the step's timeout; not both |
A complete plan
scripts.yaml runs a Node checkout and a Python checkout against the
fixture server, then checks the Node order with an http step that reads the order id through a
derived edge:
tasks:
- name: node-checkout
steps:
- script:
runtime: node
entry: ./scripts/checkout.mjs
input: { baseUrl: "{{ env.baseUrl }}", apiKey: "node-{{ run.id }}", sku: ABC-1, qty: 2 }
timeout: 30s
assert:
- ok == true
- result.total == 2000
- result.elapsedMs < 10000
results:
orderId: result.orderId
token: result.token
$ bin/vero run examples/scripts.yaml
run 01M3ZRXA2G44GH4Z68AQ2KA340 scripts.yaml --jobs 4 lifecycle unique
runtime node: /run/current-system/sw/bin/node v26.10.0
runtime python: /run/current-system/sw/bin/python3 Python 3.13.15
3 passed 0 failed 0 errored 0 timed out 0 skipped 0 blocked 0 not run (3 tasks, 1 requests, 0.2s)
The requests a script makes are its own: the count says 1, for the http step. vero plan --explain node-checkout describes the step as script ./scripts/checkout.mjs: requests unknown, made by the script, and vero plan says the plan sends at most 1 HTTP request.
The runtime
The runtime is always your own interpreter. At load, vero looks for node, or for python3 and
then python, on PATH, runs it with --version, and prints what it found in the run header and
in the run.start event, so a report from another machine explains itself. vero plan does the
same, which is one of two things it does that is not reading the file (the other is checking a
Postgres connection's role).
A plan with a script step whose runtime is missing fails to load and nothing runs:
script-fail.yaml:8: tasks.inventory.steps[0].script.runtime: runtime node is not on PATH (looked for node); task inventory needs it, and a plan with a script step fails to load without its runtime (chapter 9.3)
script-fail.yaml: 1 load error(s); nothing ran
A runtime whose --version fails is a load error too, naming the binary. A plan with no script
step never looks for one. entry must exist when the plan loads:
entry ./x.py does not exist relative to the plan file (/plans/x.py).
What the child sees
vero starts <runtime path> <entry> as an argv, no shell, in a process group of its own, with the
plan file's directory as the working directory. The environment is PATH, HOME and LANG from
vero's own environment when set, plus VERO_SCRIPT_TOKEN, a value unique to this step. Nothing
else crosses. The token is how vero finds, after the step, a process that detached itself from the
group and kept running; see below.
The child reads one JSON line from stdin: the protocol name, input with every template rendered
(strings nested inside objects and lists included), and the step's deadline in RFC 3339 UTC. It
writes events to stdout, one JSON object per line, and its stderr is kept as log lines. The
contract has the field-by-field shape.
What the outcome means
| The script | The step |
|---|---|
exits 0 with ok: true and at least one step event |
passed |
exits 0 with ok: true and no step event |
passed (unverified): vero cannot tell what it checked |
exits 0 with ok: false |
failed, reason the script returned ok: false |
| exits with a code other than 0, result event or not | errored, reason the script exited with code N |
writes a line that is not an event, a line past 1 MiB, a result without ok, or no result at all |
errored, naming the stdout line |
| is still running at the deadline | timed_out; vero sends SIGTERM to the group, SIGKILL 5 s later |
Assertions are evaluated only when a result event arrived. ok and result in an assertion
are that event's fields: ok == true, result.total == 2000. results: extract from result
as from any step. A script step's result may not be marked secret, because its result event is
written to the log as the child sends it, before vero could know what to scrub:
a script step's result cannot be secret: its result event is written as it arrives, before vero could scrub it; keep the secret out of the script's output.
A script that returned ok: true but reported no steps gets its own line in the summary block:
unverified <task> › step 1 passed (unverified): the script returned ok with no step events, so vero cannot tell what it checked. It counts as a pass for the exit code, and the JUnit and TAP
exports say it was not verified.
Names in scope
| Name | Value |
|---|---|
ok |
the result event's ok |
result |
the result event's result, decoded like a response body: exact numbers, a duplicate key refused |
duration.total |
from start to exit; the only phase a script step has |
env, tasks, run |
as in every step |
What a failure shows
The failure block prints the argv, the exit code and the time, every step event with its
status, the last 20 log lines, the result, and stderr:
FAIL inventory › step 1 script-fail.yaml
script /run/current-system/sw/bin/node ./fail.mjs
exit code 0, 79.1ms
step check inventory failed 4ms
log warn sku ABC-1 is out of stock
result ok false
A non-zero exit is an error, whatever the script said before it:
ERROR inventory › step 1 script-exit2.yaml
the script exited with code 2
script /run/current-system/sw/bin/node ./exit2.mjs
exit code 2, 98.2ms
result ok true
stderr | cannot reach the warehouse API
Both end the run with the matching exit code: 1 for the failure, 2 for the error (states and exit codes). Every known secret is scrubbed from the script's stdout events and stderr before they reach any output. A file the script writes, a screenshot or a trace, is not scrubbed.
Cancellation and leaks
On the step's deadline, a run timeout or a first SIGINT, vero sends SIGTERM to the whole
process group, then SIGKILL after 5 s, then a final SIGKILL to the group once the leader has
gone. A process that left the group, with setsid or a detached spawn, survives that. vero
gives it a second to exit on its own, looking every 50 ms, because a browser started in its own
group exits by itself once its parent's pipe closes. What is still running after that second is
reported as a leak, in the step's reason, in the failure block as leaked pid N (<command line>),
and in the summary block as leak <task> › step 1 1 process(es) left the process group and outlived the step: pid N (...). The check reads /proc and so runs on Linux only; elsewhere it
reports nothing.
Load errors
| Plan | Load error |
|---|---|
runtime: ruby |
runtime must be node or python, got "ruby" |
no entry |
a script step needs entry, the script file relative to the plan |
entry not found |
entry ./x.py does not exist relative to the plan file (...) |
timeout on the step and in script |
set the timeout once, on the step or inside script, not both |
repeat on the step |
repeat works on http steps only |
a result written { secret: ... } |
a script step's result cannot be secret: ... as above |
This page is docs/content/steps/script.md in the repository.