!Coverage !Code to Test Ratio !Test Execution Time
runn ( means "Run N". is pronounced /rʌ́n én/. ) is a package/tool for running operations following a scenario.
Key features of runn are:
- As a tool for scenario based testing.
- As a test helper package for the Go language.
- As a tool for workflow automation.
- Support HTTP request, gRPC request, DB query, Chrome DevTools Protocol, and SSH/Local command execution
- OpenAPI Document-like syntax for HTTP request testing.
- Single binary = CI-Friendly.
Online book
Quickstart
You can use the runn new command to quickly start creating scenarios (runbooks).
:rocket: Create and run scenario using curl or grpcurl commands:
`` console
$ curl https://httpbin.org/json -H "accept: application/json"
{
"slideshow": {
"author": "Yours Truly",
"date": "date of publication",
"slides": [
{
"title": "Wake up to WonderWidgets!",
"type": "all"
},
{
"items": [
"Why WonderWidgets are great",
"Who buys WonderWidgets"
],
"title": "Overview",
"type": "all"
}
],
"title": "Sample Slide Show"
}
}
$ runn new --and-run --desc 'httpbin.org GET' --out http.yml -- curl https://httpbin.org/json -H "accept: application/json"
$ grpcurl -d '{"greeting": "alice"}' grpcb.in:9001 hello.HelloService/SayHello
{
"reply": "hello alice"
}
$ runn new --and-run --desc 'grpcb.in Call' --out grpc.yml -- grpcurl -d '{"greeting": "alice"}' grpcb.in:9001 hello.HelloService/SayHello
$ runn list *.yml
Desc Path If
---------------------------------
grpcb.in Call grpc.yml
httpbin.org GET http.yml
$ runn run *.yml
..
2 scenarios, 0 skipped, 0 failures
</details>
:rocket: Create scenario using access log:
<details>
<summary>Command details</summary>
console
$ cat access_log
183.87.255.54 - - [18/May/2019:05:37:09 +0200] "GET /?post=%3script%3ealert(1); HTTP/1.0" 200 42433
62.109.16.162 - - [18/May/2019:05:37:12 +0200] "GET /core/files/js/editor.js/?form=\xeb\x2a\x5e\x89\x76\x08\xc6\x46\x07\x00\xc7\x46\x0c\x00\x00\x00\x80\xe8\xdc\xff\xff\xff/bin/sh HTTP/1.0" 200 81956
87.251.81.179 - - [18/May/2019:05:37:13 +0200] "GET /login.php/?user=admin&amount=100000 HTTP/1.0" 400 4797
103.36.79.144 - - [18/May/2019:05:37:14 +0200] "GET /authorize.php/.well-known/assetlinks.json HTTP/1.0" 200 9436
$ cat access_log| runn new --out axslog.yml
$ cat axslog.yml| yq
desc: Generated by runn new
runners:
req: https://dummy.example.com
steps:
- req:
/?post=%3script%3ealert(1);:
get:
body: null
- req:
/core/files/js/editor.js/?form=xebx2ax5ex89x76x08xc6x46x07x00xc7x46x0cx00x00x00x80xe8xdcxffxffxff/bin/sh:
get:
body: null
- req:
/login.php/?user=admin&amount=100000:
get:
body: null
- req:
/authorize.php/.well-known/assetlinks.json:
get:
body: null
$
</details>
Usage
runn can run a multi-step scenario following a runbook written in YAML format.
As a tool for scenario based testing / As a tool for automation.
runn can run one or more runbooks as a CLI tool.
console
$ runn list path/to/*/.yml
id: desc: if: steps: path
-------------------------------------------------------------------------
a1b7b02 Only if included included 2 p/t/only_if_included.yml
85ccd5f List projects. 4 p/t/p/list.yml
47d7ef7 List users. 3 p/t/u/list.yml
97f9884 Login 2 p/t/u/login.yml
2249d1b Logout 3 p/t/u/logout.yml
$ runn run path/to/*/.yml
S....
5 scenarios, 1 skipped, 0 failures
### As a test helper package for the Go language.
runn can also behave as a test helper for the Go language.
Run N runbooks using httptest.Server and sql.DB
go
func TestRouter(t *testing.T) {
ctx := context.Background()
dsn := "username:password@tcp(localhost:3306)/testdb"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
dbr, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
ts := httptest.NewServer(NewRouter(db))
t.Cleanup(func() {
ts.Close()
db.Close()
dbr.Close()
})
opts := []runn.Option{
runn.T(t),
runn.Runner("req", ts.URL),
runn.DBRunner("db", dbr),
}
o, err := runn.Load("testdata/books/*/.yml", opts...)
if err != nil {
t.Fatal(err)
}
if err := o.RunN(ctx); err != nil {
t.Fatal(err)
}
}
#### Run single runbook using httptest.Server and sql.DB
go
func TestRouter(t *testing.T) {
ctx := context.Background()
dsn := "username:password@tcp(localhost:3306)/testdb"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
dbr, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
ts := httptest.NewServer(NewRouter(db))
t.Cleanup(func() {
ts.Close()
db.Close()
dbr.Close()
})
opts := []runn.Option{
runn.T(t),
runn.Book("testdata/books/login.yml"),
runn.Runner("req", ts.URL),
runn.DBRunner("db", dbr),
}
o, err := runn.New(opts...)
if err != nil {
t.Fatal(err)
}
if err := o.Run(ctx); err != nil {
t.Fatal(err)
}
}
#### Run N runbooks using grpc.Server
go
func TestServer(t *testing.T) {
addr := "127.0.0.1:8080"
l, err := net.Listen("tcp", addr)
if err != nil {
t.Fatal(err)
}
ts := grpc.NewServer()
myapppb.RegisterMyappServiceServer(s, NewMyappServer())
reflection.Register(s)
go func() {
ts.Serve(l)
}()
t.Cleanup(func() {
ts.GracefulStop()
})
opts := []runn.Option{
runn.T(t),
runn.Runner("greq", fmt.Sprintf("grpc://%s", addr),
}
o, err := runn.Load("testdata/books/*/.yml", opts...)
if err != nil {
t.Fatal(err)
}
if err := o.RunN(ctx); err != nil {
t.Fatal(err)
}
}
#### Run N runbooks with http.Handler and sql.DB
go
func TestRouter(t *testing.T) {
ctx := context.Background()
dsn := "username:password@tcp(localhost:3306)/testdb"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
dbr, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
t.Cleanup(func() {
db.Close()
dbr.Close()
})
opts := []runn.Option{
runn.T(t),
runn.HTTPRunnerWithHandler("req", NewRouter(db)),
runn.DBRunner("db", dbr),
}
o, err := runn.Load("testdata/books/*/.yml", opts...)
if err != nil {
t.Fatal(err)
}
if err := o.RunN(ctx); err != nil {
t.Fatal(err)
}
}
## Examples
See the details
Runbook ( runn scenario file )
The runbook file has the following format.
step: section accepts list or ordered map.
List:
yaml
desc: Login and get projects.
runners:
req: https://example.com/api/v1
db: mysql://root:mypass@localhost:3306/testdb
vars:
username: alice
password: ${TEST_PASS}
steps:
-
db:
query: SELECT * FROM users WHERE name = '{{ vars.username }}'
-
req:
/login:
post:
body:
application/json:
email: "{{ steps[0].rows[0].email }}"
password: "{{ vars.password }}"
test: steps[1].res.status == 200
-
req:
/projects:
get:
headers:
Authorization: "token {{ steps[1].res.body.session_token }}"
body: null
test: steps[2].res.status == 200
-
test: len(steps[2].res.body.projects) > 0
Map:
yaml
desc: Login and get projects.
runners:
req: https://example.com/api/v1
db: mysql://root:mypass@localhost:3306/testdb
vars:
username: alice
password: ${TEST_PASS}
steps:
find_user:
db:
query: SELECT * FROM users WHERE name = '{{ vars.username }}'
login:
req:
/login:
post:
body:
application/json:
email: "{{ steps.find_user.rows[0].email }}"
password: "{{ vars.password }}"
test: steps.login.res.status == 200
list_projects:
req:
/projects:
get:
headers:
Authorization: "token {{ steps.login.res.body.session_token }}"
body: null
test: steps.list_projects.res.status == 200
count_projects:
test: len(steps.list_projects.res.body.projects) > 0
#### Grouping of related parts by color
List:
Map:
JSON Schema
runbook.schema.yaml.
You can use it with YAML Language Server for editor validation and autocompletion by adding the following comment to the top of your runbook:
yaml
yaml-language-server: $schema=https://raw.githubusercontent.com/k1LoW/runn/main/runbook.schema.yaml
### desc:
Description of runbook.
yaml
desc: Login and get projects.
runners:
req: https://example.com/api/v1
vars:
username: alice
steps:
[...]
### labels:
Labels of runbook.
yaml
desc: Login
runners:
req: https://example.com/api/v1
labels:
- users
- auth
steps:
[...]
Runbooks to be run can be filtered by labels.
console
$ runn run path/to/*/.yml --label users --label projects
console
$ runn run path/to/*/.yml --label 'users and auth'
### runners:
Mapping of runners that run
steps: of runbook.
In the
steps: section, call the runner with the key specified in the runners: section.
Built-in runners such as test runner do not need to be specified in this section.
yaml
runners:
ghapi: ${GITHUB_API_ENDPOINT}
idp: https://auth.example.com
db: my:dbuser:${DB_PASS}@hostname:3306/dbname
In the example, each runner can be called by ghapi:, idp: or db: in steps:.
hostRules:
Allows remapping any request hostname to another hostname, IP address in HTTP/gRPC/DB/CDP/SSH runners.
yaml
hostRules:
example.com: 127.0.0.1:8080
'*.example.test': 192.168.0.16
### vars:
Mapping of variables available in the
steps: of runbook.
yaml
vars:
username: [email protected]
token: ${SECRET_TOKEN}
In the example, each variable can be used in {{ vars.username }} or {{ vars.token }} in steps:.
secrets:
List of secret var names to be masked.
yaml
secrets:
- vars.secret_token
- binded_password
- current.res.message.token
### debug:
Enable debug output for runn.
yaml
debug: true
#### Show debug output only for failed steps
Use
--debug-on-failure to keep the normal output for successful steps and print runner diagnostics only for failed steps.
The diagnostics are written to stderr and include command/stdout/stderr for Exec and SSH runners, stdin for Exec runners, and the request and response details captured for other runners.
console
$ runn run path/to/*/.yml --verbose --debug-on-failure
### interval:
Interval between steps.
yaml
interval: 1
### if:
Conditions for skip all steps.
yaml
if: included # Run steps only if included
### skipTest:
Skip all
test: sections
yaml
skipTest: true
### force:
Force all steps to run.
yaml
force: true
### trace:
Add tokens for tracing to headers and queries by default.
Currently, HTTP runner, gRPC runner and DB runner are supported.
yaml
trace: true
### loop:
Loop setting for runbook.
Simple loop runbook
yaml
loop: 10
steps:
[...]
or
yaml
loop:
count: 10
steps:
[...]
#### Retry runbook
It can be used as a retry mechanism by setting a condition in the
until: section.
If the condition of
until: is met, the loop is broken without waiting for the number of count: to be run.
Also, if the run of the number of
count: completes but does not satisfy the condition of until:, then the step is considered to be failed.
yaml
loop:
count: 10
until: 'outcome == "success"' # until the runbook outcome is successful.
minInterval: 0.5 # sec
maxInterval: 10 # sec
# jitter: 0.0
# interval: 5
# multiplier: 1.5
steps:
waitingroom:
req:
/cart/in:
post:
body:
[...]
- outcome ... the result of a completed (success, failure, skipped).
concurrency:
Runbooks with the same key are assured of a single run at the same time.
yaml
concurrency: use-shared-db
or
yaml
concurrency:
- use-shared-db
- use-shared-api
### needs:
It is possible to identify runbooks that must be pre-run.
yaml
needs:
prebook: path/to/prebook.yml
prebook2: path/to/prebook2.yml
Values bound by the bind runner can be referenced by needs.<key>. *.
steps:
Steps to run in runbook.
The steps are invoked in order from top to bottom.
Any return values are recorded for each step.
When
steps: is array, recorded values can be retrieved with {{ steps[]. }}.
yaml
steps:
-
db:
query: SELECT * FROM users WHERE name = '{{ vars.username }}'
-
req:
/users/{{ steps[0].rows[0].id }}:
get:
body: null
When steps: is map, recorded values can be retrieved with {{ steps.<key>.* }}.
yaml
steps:
find_user:
db:
query: SELECT * FROM users WHERE name = '{{ vars.username }}'
user_info:
req:
/users/{{ steps.find_user.rows[0].id }}:
get:
body: null
### steps[*].desc: steps.<key>.desc:
Description of step.
yaml
steps:
-
desc: Login
req:
/login:
post:
body:
[...]
### steps[*].if: steps.<key>.if:
Conditions for skip step.
yaml
steps:
login:
if: 'len(vars.token) == 0' # Run step only if var.token is not set
req:
/login:
post:
body:
[...]
### steps[*].loop: steps.<key>.loop:
Loop setting for step.
Simple loop step
yaml
steps:
multicartin:
loop: 10
req:
/cart/in:
post:
body:
application/json:
product_id: "{{ i }}" # The loop count (0..9) is assigned to i.
[...]
or
yaml
steps:
multicartin:
loop:
count: 10
req:
/cart/in:
post:
body:
application/json:
product_id: "{{ i }}" # The loop count (0..9) is assigned to i.
[...]
#### Retry step
It can be used as a retry mechanism by setting a condition in the
until: section.
If the condition of
until: is met, the loop is broken without waiting for the number of count: to be run.
Also, if the run of the number of
count: completes but does not satisfy the condition of until:, then the step is considered to be failed.
yaml
steps:
waitingroom:
loop:
count: 10
until: 'steps.waitingroom.res.status == "201"' # Store values of latest loop
minInterval: 500ms
maxInterval: 10 # sec
# jitter: 0.0
# interval: 5
# multiplier: 1.5
req:
/cart/in:
post:
body:
[...]
#### Execution order within a loop
When using
loop:, it's important to understand the execution order of runners within each iteration:
- Main runner (HTTP, DB, gRPC, CDP, SSH, Exec, Include, or Runner) - Only one is executed
- Dump runner - Records request/response data (if specified)
- Bind runner - Binds variables (if specified)
- Test runner - Performs assertions (if specified)
This sequence repeats for each loop iteration.
⚠️ Warning: Avoid using
test: with loop.until:
When using
loop: with an until: condition, avoid also using test: in the same step. The test: assertion runs on every loop iteration, which can cause the step to fail on the first iteration before the retry logic has a chance to work.
❌ Incorrect usage:
yaml
steps:
retry_until_success:
req:
/status:
get:
loop:
count: 10
until: 'current.res.status == 200' # Retry until successful
test: 'current.res.status == 200' # This runs every iteration and may fail early!
✅ Correct usage:
yaml
steps:
retry_until_success:
req:
/status:
get:
loop:
count: 10
until: 'current.res.status == 200' # This is sufficient for retry logic
The until: condition already serves as your test condition for retry scenarios.
steps[*].defer: steps.<key>.defer: [THIS IS EXPERIMENT]
Deferring setting for step.
yaml
steps:
-
defer: true
req:
/cart:
delete:
body: null
[...]
The step marked defer behaves as follows.
- If
defer: true is set, run of the step is deferred until finish of the runbook.
Steps marked with defer are always run even if the running of intermediate steps fails.
If there are multiple steps marked with defer, they are run in LIFO order. - Also, the included steps are added to run sequence of the parent runbook's deferred steps.
steps[*].force: steps.<key>.force:
Force step to run.
yaml
steps:
[...]
-
force: true
dump: previous.res.body
[...]
## Variables to be stored
runn can use variables and functions when running step.
Also, after step runs, HTTP responses, DB query results, etc. are automatically stored in variables.
The values are stored in predefined variables.
| Variable name | Description |
| --- | --- |
|
vars | Values set in the vars: section |
| steps | Return values for each step |
| i | Loop index (only in loop: section) |
| env | Environment variables |
| current | Return values of current step |
| previous | Return values of previous step |
| parent | Variables of parent runbook (only included) |
env is a snapshot of the process environment taken at the start of each runbook run. It does not change while the runbook is running, and included runbooks share the snapshot of the runbook that includes them.
Variable Expansion
runn uses
{{ }} syntax for variable expansion.
Object variable expansion
When an object variable is used directly in
headers: or body:, runn expands it as a map structure, not as a JSON string.
yaml
vars:
auth_headers:
X-Token: xxx
X-Api-Key: yyy
steps:
- req:
/api:
get:
headers: "{{vars.auth_headers}}"
In the example, vars.auth_headers expands to multiple headers.
Converting object to JSON string
To convert an object to a JSON string, wrap the entire
{{...}} template expression in single quotes. This signals runn to serialize the object to JSON.
yaml
vars:
metadata:
user: alice
role: admin
steps:
- req:
/api:
get:
headers:
X-Metadata: "'{{vars.metadata}}'"
## Runner
HTTP Runner: Do HTTP request
Use
https:// or http:// scheme to specify HTTP Runner.
When the step is invoked, it sends the specified HTTP Request and records the response.
yaml
runners:
req: https://example.com
steps:
-
desc: Post /users # description of step
req: # key to identify the runner. In this case, it is HTTP Runner.
/users: # path of http request
post: # method of http request
headers: # headers of http request
Authorization: 'Bearer xxxxx'
body: # body of http request
application/json: # Content-Type specification. In this case, it is "Content-Type: application/json"
username: alice
password: passw0rd
trace: false # add X-Runn-Trace header to HTTP request for tracing
test: | # test for current step
current.res.status == 201
See testdata/book/http.yml and testdata/book/http_multipart.yml.
Structure of recorded responses
The following response
HTTP/1.1 200 OK
Content-Length: 29
Content-Type: application/json
Date: Wed, 07 Sep 2022 06:28:20 GMT
Set-Cookie: cookie-name=cookie-value
{"data":{"username":"alice"}}
is recorded with the following structure.
yaml
[step key or current or previous]:
res:
status: 200 # current.res.status
headers:
Content-Length:
- '29' # current.res.headers["Content-Length"][0]
Content-Type:
- 'application/json' # current.res.headers["Content-Type"][0]
Date:
- 'Wed, 07 Sep 2022 06:28:20 GMT' # current.res.headers["Date"][0]
Set-Cookie:
- 'cookie-name=cookie-value' # current.res.headers["Set-Cookie"][0]
cookies:
cookie-name: *http.Cookie # current.res.cookies["cookie-name"].Value
body:
data:
username: 'alice' # current.res.body.data.username
rawBody: '{"data":{"username":"alice"}}' # current.res.rawBody
#### Do not follow redirect
The HTTP Runner interprets HTTP responses and automatically redirects.
To disable this, set
notFollowRedirect to true.
yaml
runners:
req:
endpoint: https://example.com
notFollowRedirect: true
#### Enable Cookie Sending
The HTTP Runner automatically saves cookies by interpreting HTTP responses.
To enable cookie sending during requests, set
useCookie to true.
yaml
runners:
req:
endpoint: https://example.com
useCookie: true
See testdata/book/cookie.yml and testdata/book/cookie_in_requests_automatically.yml.
Validation of HTTP request and HTTP response
HTTP requests sent by
runn and their HTTP responses can be validated.
OpenAPI v3:
yaml
runners:
myapi:
endpoint: https://api.example.com
openapi3: path/to/openapi.yaml
# skipValidateRequest: false
# skipValidateResponse: false
# skipCircularReferenceCheck: false # skip checking circular references in OpenAPIv3 document.
#### Custom CA and Certificates
yaml
runners:
myapi:
endpoint: https://api.github.com
cacert: path/to/cacert.pem
cert: path/to/cert.pem
key: path/to/key.pem
# skipVerify: false
#### Add X-Runn-Trace header to HTTP request for tracing
yaml
runners:
myapi:
endpoint: https://api.github.com
trace: true
### gRPC Runner: Do gRPC request
Use
grpc:// scheme to specify gRPC Runner.
When the step is invoked, it sends the specified gRPC Request and records the response.
yaml
runners:
greq: grpc://grpc.example.com:80
steps:
-
desc: Request using Unary RPC # description of step
greq: # key to identify the runner. In this case, it is gRPC Runner.
grpctest.GrpcTestService/Hello: # package.Service/Method of rpc
headers: # headers of rpc
authentication: tokenhello
message: # message of rpc
name: alice
num: 3
request_time: 2022-06-25T05:24:43.861872Z
trace: false # add x-runn-trace header to gRPC request for tracing
-
desc: Request using Server streaming RPC
greq:
grpctest.GrpcTestService/ListHello:
headers:
authentication: tokenlisthello
message:
name: bob
num: 4
request_time: 2022-06-25T05:24:43.861872Z
timeout: 3sec # timeout for rpc
test: |
steps.server_streaming.res.status == 0 && len(steps.server_streaming.res.messages) > 0
-
desc: Request using Client streaming RPC
greq:
grpctest.GrpcTestService/MultiHello:
headers:
authentication: tokenmultihello
messages: # messages of rpc
-
name: alice
num: 5
request_time: 2022-06-25T05:24:43.861872Z
-
name: bob
num: 6
request_time: 2022-06-25T05:24:43.861872Z
yaml
runners:
greq:
addr: grpc.example.com:8080
tls: true
cacert: path/to/cacert.pem
cert: path/to/cert.pem
key: path/to/key.pem
# skipVerify: false
# importPaths:
# - protobuf/proto
# protos:
# - general/health.proto
# - myapp/*/.proto
See testdata/book/grpc.yml.
Structure of recorded responses
The following response
protocol-buffer
message HelloResponse {
string message = 1;
int32 num = 2;
google.protobuf.Timestamp create_time = 3;
}
json
{"create_time":"2022-06-25T05:24:43.861872Z","message":"hello","num":32}
and headers
yaml
content-type: ["application/grpc"]
hello: ["this is header"]
and trailers
yaml
hello: ["this is trailer"]
are recorded with the following structure.
yaml
[step key or current or previous]:
res:
status: 0 # current.res.status
headers:
content-type:
- 'application/grpc' # current.res.headers[0].content-type
hello:
- 'this is header' # current.res.headers[0].hello
trailers:
hello:
- 'this is trailer' # current.res.trailers[0].hello
message:
create_time: '2022-06-25T05:24:43.861872Z' # current.res.message.create_time
message: 'hello' # current.res.message.message
num: 32 # current.res.message.num
messages:
-
create_time: '2022-06-25T05:24:43.861872Z' # current.res.messages[0].create_time
message: 'hello' # current.res.messages[0].message
num: 32 # current.res.messages[0].num
#### Add x-runn-trace header to gRPC request for tracing
yaml
runners:
greq:
addr: grpc.example.com:8080
trace: true
#### Buf
gRPC Runner supports Buf ecosystem includes Buf Schema Registry.
It can use the buf modules ( and protos ) it depends on.
yaml
runners:
greq:
addr: grpc.example.com:8080
bufDirs:
- path/to # Set buf directories for registering buf modules and protos
yaml
runners:
greq:
addr: grpc.example.com:8080
bufLocks:
- path/to/buf.lock # Register buf modules using buf.lock
yaml
runners:
greq:
addr: grpc.example.com:8080
bufConfigs:
- path/to/buf.yaml # Register buf modules using buf.yaml
yaml
runners:
greq:
addr: grpc.example.com:8080
bufModules:
- buf.build/owner/repository
- buf.build/owner2/repository2
### DB Runner: Query a database
Use dsn (Data Source Name) to specify DB Runner.
When step is invoked, it executes the specified query the database.
yaml
runners:
db: postgres://dbuser:dbpass@hostname:5432/dbname
steps:
-
desc: Select users # description of step
db: # key to identify the runner. In this case, it is DB Runner.
query: SELECT * FROM users; # query to execute
trace: false # add comment with trace token to query for tracing
See testdata/book/db.yml.
Structure of recorded responses
If the query is a SELECT clause, it records the selected
rows,
yaml
[step key or current or previous]:
rows:
-
id: 1 # current.rows[0].id
username: 'alice' # current.rows[0].username
password: 'passw0rd' # current.rows[0].password
email: '[email protected]' # current.rows[0].email
created: '2017-12-05T00:00:00Z' # current.rows[0].created
-
id: 2 # current.rows[1].id
username: 'bob' # current.rows[1].username
password: 'passw0rd' # current.rows[1].password
email: '[email protected]' # current.rows[1].email
created: '2022-02-22T00:00:00Z' # current.rows[1].created
otherwise it records last_insert_id and rows_affected .
yaml
[step key or current or previous]:
last_insert_id: 3 # current.last_insert_id
rows_affected: 1 # current.rows_affected
#### Add comment with trace token to query for tracing
yaml
runners:
db:
dsn: mysql://dbuser:dbpass@hostname:3306/dbname
trace: true
#### Support Databases
PostgreSQL:
yaml
runners:
mydb: postgres://dbuser:dbpass@hostname:5432/dbname
yaml
runners:
db: pg://dbuser:dbpass@hostname:5432/dbname
MySQL:
yaml
runners:
testdb: mysql://dbuser:dbpass@hostname:3306/dbname
yaml
runners:
db: my://dbuser:dbpass@hostname:3306/dbname
SQLite3:
yaml
runners:
db: sqlite:///path/to/dbname.db
yaml
runners:
local: sq://dbname.db
Cloud Spanner:
yaml
runners:
testdb: spanner://test-project/test-instance/test-database
yaml
runners:
db: sp://test-project/test-instance/test-database
### CDP Runner: Control browser using Chrome DevTools Protocol (CDP)
Use
cdp:// or chrome:// scheme to specify CDP Runner.
When the step is invoked, it controls browser via Chrome DevTools Protocol.
yaml
runners:
cc: chrome://new
steps:
-
desc: Navigate, click and get h1 using CDP # description of step
cc: # key to identify the runner. In this case, it is CDP Runner.
actions: # actions to control browser
- navigate: https://pkg.go.dev/time
- click: 'body > header > div.go-Header-inner > nav > div > ul > li:nth-child(2) > a'
- waitVisible: 'body > footer'
- text: 'h1'
-
test: |
previous.text == 'Install the latest version of Go'
#### CDP Configuration Options
The CDP runner supports additional configuration options:
yaml
runners:
cc:
addr: chrome://new # or cdp://new
timeout: 120sec # Timeout for each CDP action (default: 60s)
flags: # Chrome browser flags
headless: true
disable-gpu: true
no-sandbox: true
Configuration parameters:
addr: Chrome DevTools Protocol address. Use chrome://new or cdp://new to launch a new browser instance
timeout: Timeout duration for each CDP action/step (e.g., "30s", "2m", "1m30s"). Default is 60 seconds
flags: Chrome browser launch flags as key-value pairs
See testdata/book/cdp.yml.
Functions for action to control browser
<!-- repin:fndoc -->
attributes (aliases: getAttributes, attrs, getAttrs)
Get the element attributes for the first element node matching the selector (
sel).
yaml
actions:
- attributes:
sel: "h1"
record to current.attrs:
or
yaml
actions:
- attributes: "h1"
click
Send a mouse click event to the first element node matching the selector (
sel).
yaml
actions:
- click:
sel: "nav > div > a"
or
yaml
actions:
- click: "nav > div > a"
doubleClick
Send a mouse double click event to the first element node matching the selector (
sel).
yaml
actions:
- doubleClick:
sel: "nav > div > li"
or
yaml
actions:
- doubleClick: "nav > div > li"
evaluate (aliases: eval)
Evaluate the Javascript expression (
expr).
yaml
actions:
- evaluate:
expr: "document.querySelector(\"h1\").textContent = \"hello\""
or
yaml
actions:
- evaluate: "document.querySelector(\"h1\").textContent = \"hello\""
fullHTML (aliases: getFullHTML, getHTML, html)
Get the full html of page.
yaml
actions:
- fullHTML
record to current.html:
innerHTML (aliases: getInnerHTML)
Get the inner html of the first element node matching the selector (
sel).
yaml
actions:
- innerHTML:
sel: "h1"
record to current.html:
or
yaml
actions:
- innerHTML: "h1"
localStorage (aliases: getLocalStorage)
Get localStorage items.
yaml
actions:
- localStorage:
origin: "https://github.com"
record to current.items:
or
yaml
actions:
- localStorage: "https://github.com"
location (aliases: getLocation)
Get the document location.
yaml
actions:
- location
record to current.url:
navigate
Navigate the current frame to
url page.
yaml
actions:
- navigate:
url: "https://pkg.go.dev/time"
or
yaml
actions:
- navigate: "https://pkg.go.dev/time"
outerHTML (aliases: getOuterHTML)
Get the outer html of the first element node matching the selector (
sel).
yaml
actions:
- outerHTML:
sel: "h1"
record to current.html:
or
yaml
actions:
- outerHTML: "h1"
screenshot (aliases: getScreenshot)
Take a full screenshot of the entire browser viewport.
yaml
actions:
- screenshot
record to current.png:
scroll (aliases: scrollIntoView)
Scroll the window to the first element node matching the selector (
sel).
yaml
actions:
- scroll:
sel: "body > footer"
or
yaml
actions:
- scroll: "body > footer"
sendKeys
Send keys (
value) to the first element node matching the selector (sel).
yaml
actions:
- sendKeys:
sel: "input[name=username]"
value: "[email protected]"
sessionStorage (aliases: getSessionStorage)
Get sessionStorage items.
yaml
actions:
- sessionStorage:
origin: "https://github.com"
record to current.items:
or
yaml
actions:
- sessionStorage: "https://github.com"
setUploadFile (aliases: setUpload)
Set upload file (
path) to the first element node matching the selector (sel).
yaml
actions:
- setUploadFile:
sel: "input[name=avator]"
path: "/path/to/image.png"
setUserAgent (aliases: setUA, ua, userAgent)
Set the default User-Agent
yaml
actions:
- setUserAgent:
userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36"
or
yaml
actions:
- setUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36"
submit
Submit the parent form of the first element node matching the selector (
sel).
yaml
actions:
- submit:
sel: "form.login"
or
yaml
actions:
- submit: "form.login"
tabTo
Change current frame to the tab with the specified
url.
yaml
actions:
- tabTo:
url: "https://pkg.go.dev/time"
or
yaml
actions:
- tabTo: "https://pkg.go.dev/time"
text (aliases: getText)
Get the visible text of the first element node matching the selector (
sel).
yaml
actions:
- text:
sel: "h1"
record to current.text:
or
yaml
actions:
- text: "h1"
textContent (aliases: getTextContent)
Get the text content of the first element node matching the selector (
sel).
yaml
actions:
- textContent:
sel: "h1"
record to current.text:
or
yaml
actions:
- textContent: "h1"
title (aliases: getTitle)
Get the document
title.
yaml
actions:
- title
record to current.title:
value (aliases: getValue)
Get the Javascript value field of the first element node matching the selector (
sel).
yaml
actions:
- value:
sel: "input[name=address]"
record to current.value:
or
yaml
actions:
- value: "input[name=address]"
wait (aliases: sleep)
Wait for the specified
time.
yaml
actions:
- wait:
time: "10sec"
or
yaml
actions:
- wait: "10sec"
waitReady
Wait until the element matching the selector (
sel) is ready.
yaml
actions:
- waitReady:
sel: "body > footer"
or
yaml
actions:
- waitReady: "body > footer"
waitVisible
Wait until the element matching the selector (
sel) is visible.
yaml
actions:
- waitVisible:
sel: "body > footer"
or
yaml
actions:
- waitVisible: "body > footer"
<!-- repin:fndoc -->
SSH Runner: execute commands on a remote server connected via SSH
Use
ssh:// scheme to specify SSH Runner.
When step is invoked, it executes commands on a remote server connected via SSH.
yaml
runners:
sc: ssh://username@hostname:port
steps:
-
desc: 'execute hostname' # description of step
sc:
command: hostname
yaml
runners:
sc:
hostname: hostname
user: username
port: 22
# host: myserver
# sshConfig: path/to/ssh_config
# keepSession: false
# localForward: '33306:127.0.0.1:3306'
# keyboardInteractive:
# - match: Username
# answer: k1low
# - match: OTP
# answer: ${MY_OTP}
See testdata/book/sshd.yml.
Structure of recorded responses
The response to the run command is always
stdout and stderr.
yaml
[step key or current or previous]:
stdout: 'hello world' # current.stdout
stderr: '' # current.stderr
### Exec Runner: execute command
> Note
> Exec runner requires
run:exec scope to run.
The
exec runner is a built-in runner, so there is no need to specify it in the runners: section.
It executes command using
command:, stdin:, shell:, background:, liveOutput: and env:.
yaml
- exec:
command: grep hello
stdin: '{{ steps[3].res.rawBody }}'
yaml
- exec:
command: echo $0
shell: bash
background: set to true to run the command in the background.
yaml
- exec:
command: kubectl port-forward svc/nginx 8080:80
liveOutput: true
liveOutput: set to true to output the command output live.
yaml
- exec:
command: |
echo "Start heavy commands"
sleep 5
echo "Heavy command finished"
liveOutput: true
env: sets additional environment variables for the command execution.
yaml
- exec:
command: printenv MY_VAR
env:
MY_VAR: hello
ANOTHER_VAR: "{{ vars.value }}"
See testdata/book/exec.yml.
Structure of recorded responses
The response to the run command is always
stdout, stderr and exit_code.
yaml
[step key or current or previous]:
stdout: 'hello world' # current.stdout
stderr: '' # current.stderr
exit_code: 0 # current.exit_code
#### exec.shell:
Use
shell: to define the shell and options to be used by the Exec runner.
| Parameter| Command run internally |
|:----|:----|
| unspecified |
bash -e -c {0} |
| bash | bash --noprofile --norc -eo pipefail -c {0} |
| sh | sh -e -c {0} |
Test Runner: test using recorded values
The
test runner is a built-in runner, so there is no need to specify it in the runners: section.
It evaluates the conditional expression using the recorded values.
yaml
- test: steps[3].res.status == 200
The test runner can run in the same steps as the other runners.
Dump Runner: dump recorded values
The
dump runner is a built-in runner, so there is no need to specify it in the runners: section.
It dumps the specified recorded values.
yaml
- dump: steps[4].rows
or
yaml
- dump:
expr: steps[4].rows
out: path/to/dump.out
disableTrailingNewline: true # disable trailing newline. default is false
disableMaskingSecrets: true # disable masking secrets. default is false
The dump runner can run in the same steps as the other runners.
Include Runner: include other runbook
The
include runner is a built-in runner, so there is no need to specify it in the runners: section.
Include runner reads and runs the runbook in the specified path.
Recorded values are nested.
yaml
- include: path/to/get_token.yml
It is also possible to override vars: of included runbook.
yaml
- include:
path: path/to/login.yml
vars:
username: alice
password: alicepass
- include:
path: path/to/login.yml
vars:
username: bob
password: bobpass
It is also possible to skip all test: sections in the included runbook.
yaml
- include:
path: path/to/signup.yml
skipTest: true
It is also possible to force all steps in the included runbook to run.
yaml
- include:
path: path/to/signup.yml
force: true
#### Inline include
Instead of referencing an external file, you can write runbook steps inline.
yaml
- include:
steps:
- exec:
command: echo hello
test: current.stdout == "hello\n"
Inline includes can also define desc:, runners:, and vars:.
Note that
vars: in inline includes defines the inline runbook's own variables (equivalent to the top-level vars: in an external runbook), not override variables.
yaml
- include:
desc: setup user
runners:
db: ${TEST_DB}
vars:
name: alice
steps:
- db:
query: "INSERT INTO users (name) VALUES ('{{ vars.name }}')"
skipTest: true
### Bind Runner: bind variables
The
bind runner is a built-in runner, so there is no need to specify it in the runners: section.
It bind runner binds any values with another key.
yaml
-
req:
/users/k1low:
get:
body: null
-
bind:
user_id: steps[0].res.body.data.id
-
dump: user_id
The bind runner can run in the same steps as the other runners.
Runner Runner: Define runner in the middle of steps.
The
runner runner is a built-in runner, so there is no need to specify it in the runners: section.
It defines a runner in the middle of steps.
yaml
-
runner:
sc: ssh://username@hostname:port
-
sc:
command: hostname
The runner runner can not run in the same steps as the other runners.
Agent Runner: Interact with AI agents (experimental)
The agent runner sends prompts to external AI agents and records responses. It requires
run:agent scope.
console
$ runn run --scopes run:agent runbook.yml
#### Supported agents
| Agent | SDK | Description |
|-------|-----|-------------|
|
claude | claude-agent-sdk-go | Claude Code agent |
| copilot | copilot-sdk | GitHub Copilot agent |
| codex | codex-agent-sdk-go | OpenAI Codex agent |
Basic usage
yaml
runners:
claude:
agent: claude
model: sonnet
system: "You are a helpful assistant."
permissions:
- "allow:*"
steps:
-
claude:
prompt: "What is Go?"
test: current.res.content != ''
The expanded prompt is stored in steps[N].prompt and the response in steps[N].res.content.
Conversation context
Each step sends a single prompt. Conversation context is maintained per runner definition across steps. Use
clearContext: true to reset.
yaml
steps:
-
claude:
prompt: "What is Go?"
-
claude:
prompt: "Tell me more about its concurrency model"
-
claude:
prompt: "Completely different topic"
clearContext: true
Different runner definitions have independent contexts, even if they use the same agent and model.
Collaboration with other runners
Agent runner results can be referenced by other runners via expression expansion.
yaml
runners:
req:
endpoint: http://api.example.com
claude:
agent: claude
model: sonnet
system: "Summarize articles in Japanese."
permissions:
- "allow:*"
steps:
-
req:
/articles/1:
get:
body: null
-
claude:
prompt: "{{ steps[0].res.body.content }}"
test: current.res.content != ''
#### Configuration fields
| Field | Description |
|-------|-------------|
|
agent | Agent framework (claude, copilot, codex) |
| provider | LLM provider (optional if same as agent. e.g. openai for copilot) |
| model | Model name |
| system | System prompt |
| tools | Available tools (restricts which tools the agent can see) |
| permissions | Tool permission rules (see below) |
| interactive | Enable interactive mode (true/false) |
Permissions
The
permissions field controls what the agent is allowed to do. Rules are evaluated in array order (last match wins).
yaml
permissions:
- "allow:*" # allow all tools
- "deny:Write" # but deny Write (overrides allow:*)
yaml
permissions:
- "deny:*" # deny all tools
- "allow:Read" # but allow Read (overrides deny:*)
Rule formats:
| Format | Description |
|--------|-------------|
|
allow:* | Allow all tools/operations |
| deny:* | Deny all tools/operations |
| allow:ToolName | Allow a specific tool |
| deny:ToolName | Deny a specific tool |
| sandbox:mode | Sandbox mode (Codex only) |
| Other values | SDK-specific mode (see per-agent sections below) |
interactive: true: When set, tools not matched by any rule will prompt the user for confirmation. Agent questions (e.g. AskUserQuestion) are also enabled. When false (default), unmatched tools are denied and agent questions cause an error.
Permissions per agent
##### Claude
Claude uses Claude Code CLI under the hood.
yaml
runners:
claude:
agent: claude
model: sonnet
tools:
- Read
- Bash
permissions:
- "allow:Read"
- "allow:Bash(git:*)" # pattern matching supported
- "deny:Bash(rm *)"
- acceptEdits # SDK-specific permission mode
interactive: true
| Format | Claude mapping |
|--------|----------------|
| allow:ToolName | --allowedTools (auto-approved, no callback) |
| deny:ToolName | --disallowedTools (blocked) |
| acceptEdits, plan, bypassPermissions, ... | --permission-mode |
Claude supports tool name patterns (e.g.
Bash(git:*)) in both allow: and deny: rules. Available tools are configured via tools field (--tools).
When
interactive: true, unmatched tools are confirmed via terminal prompt, and agent questions (AskUserQuestion) are answered interactively.
##### Copilot
Copilot uses GitHub Copilot CLI under the hood.
yaml
runners:
helper:
agent: copilot
provider: openai
model: gpt-5-nano
system: "You are a data analyst."
tools:
- web_search
permissions:
- "allow:*"
interactive: true
| Format | Copilot mapping |
|--------|-----------------|
| allow:ToolName | Approved via OnPermissionRequest callback |
| deny:ToolName | ExcludedTools + denied via callback |
| SDK-specific modes | Not supported (error) |
Copilot's permission model is based on operation kinds (
shell, write, read, url, etc.) rather than tool names. The allow:/deny: rules match against the tool name in the permission request.
When
interactive: true, unmatched operations are confirmed via terminal prompt, and user input requests are answered interactively.
##### Codex
Codex uses OpenAI Codex CLI under the hood.
yaml
runners:
codex:
agent: codex
model: o3
permissions:
- "allow:*"
- full-auto
- "sandbox:workspace-write"
`
| Format | Codex mapping |
|--------|---------------|
|
allow:* (with no mode set) | --approval-policy full-auto |
| deny:ToolName | Declined via OnCommandApproval callback |
| full-auto, auto-edit, suggest, ... | --approval-policy |
| sandbox:workspace-write, sandbox:workspace-read, ... | --sandbox |
Codex has two SDK-specific settings:
- Approval policy — Controls how commands/file changes are approved. Set as a plain string in permissions (e.g.
full-auto).
Sandbox mode — Controls the execution sandbox. Set with sandbox: prefix (e.g. sandbox:workspace-write).
When interactive: true`, command executions and file changes are confirmed via terminal prompt.
Expression evaluation engine
runn has embedded expr-lang/expr as the evaluation engine for the expression.