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.

verodocs