ringo-flow 0.10.1

Declarative telephony scenario test runner for baresip, built on ringo-core
# ringo-flow

[![crates.io](https://img.shields.io/crates/v/ringo-flow)](https://crates.io/crates/ringo-flow)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE)

> Declarative telephony tests for [baresip]https://github.com/baresip/baresip:
> script SIP call scenarios and assert what happens.

> [!WARNING]
> **The scenario API is not stable yet.** ringo-flow shares the workspace version
> and is still pre-1.0 (`0.x`); verbs, getters, output and behaviour may change in
> **breaking** ways between releases. Pin an exact version if you depend on it.

ringo-flow runs automated **call tests**. A scenario is a [Rhai](https://rhai.rs)
script that brings up one or more SIP **agents** (each a headless baresip
instance), drives them — register, dial, accept, transfer, send DTMF, play and
verify audio — and **asserts** the outcome. Assertions are event-driven: they
wait for the expected state instead of sleeping, and the run exits non-zero on
the first failure. No sound hardware needed; it's built on the shared
[`ringo-core`](../ringo-core) engine.

## Use cases

- **Regression-test a PBX / SIP setup** — registration, call routing, rejection,
  hold, blind & attended transfer, conferences.
- **CI for telephony backends** — fully headless (virtual audio), so it runs on a
  build server with no devices.
- **End-to-end feature checks** — DTMF/IVR navigation, MWI, custom SIP headers,
  and real two-way **audio** (tone detection over the media path).
- **Cross-check a backend API** — make HTTP calls mid-scenario and assert the
  system recorded the call (e.g. a correlation id carried on an inbound INVITE).
- **Webhook-driven call control** — stand up a built-in HTTP mock server, let the
  system under test call its webhook for a call, and answer with the actions it
  should perform, then assert on the requests it received. Routes
  match by exact path or `regex(...)`, and by a given method or any (`"*"` / no
  method argument).

## How it works

```rhai
let a = agent("A", #{ username: env("A_USER"), domain: env("SIP_DOMAIN"), password: env("A_PASS") });
let b = agent("B", #{ username: env("B_USER"), domain: env("SIP_DOMAIN"), password: env("B_PASS") });

a.register();
await_until(|| assert(a.registered).is_true());

a.dial(b);
await_until(|| assert(b.state).equals(State::Ringing));
b.accept();
await_until(|| assert(a.state).equals(State::Established));
```

- **Agents** are SIP endpoints you create and drive with verbs (`register`,
  `dial`, `accept`, `hangup`, `hold`, `transfer`, `dtmf`, `send_audio`, …).
- **`assert(x).<matcher>(…)`** is a fluent, auto-labeled check; wrap it in
  **`await_until(|| …)`** to wait for async state to settle (default timeout,
  overridable per call).
- A file with `scenario("name", |ctx| { … })` calls becomes a **suite** — each
  scenario runs in isolation with `setup`/`teardown`; otherwise the whole script
  is a single scenario.
- Credentials come from the **environment** (`env(...)`, `--env-file`, a per-file
  `<scenario>.env`) so secrets stay out of scripts.

It's a normal Rhai script, so variables, `if`/`for` and `fn` definitions all work.
The full verb / getter / matcher list — with signatures — is in the generated
[**scenario API reference**](docs/scenario-api.md).

## Getting started

Requires [baresip](https://github.com/baresip/baresip) >= 3.14 in `$PATH`.

```sh
cargo install --git https://github.com/davidborzek/ringo ringo-flow
# …or from a workspace checkout: cargo run -p ringo-flow -- run scenario.rhai

SIP_DOMAIN=example.com A_USER=alice A_PASS=… B_USER=bob B_PASS=… \
  ringo-flow run scenario.rhai
```

```sh
ringo-flow run scenario.rhai     # one file
ringo-flow run scenarios/        # a directory (all *.rhai, recursively)
ringo-flow check scenario.rhai   # syntax-check only (no baresip)
```

Useful flags: `--scenario <pattern>` (run a subset by name; `re:` for regex),
`--tag <tag>` / `--exclude-tag <tag>` (filter by tag), `--env-file FILE`, `--logs`
(print SIP signaling), `--save-audio`, `--json` (NDJSON for CI), `-q`/`-v`,
`--no-color`, `--insecure-http` (skip TLS verification for `http(...)`). The exit
code is non-zero if any scenario fails. See `ringo-flow run --help` for the full
list.

### Selecting, tagging and skipping scenarios

Scenarios in a suite take an options map — `scenario(name, #{ … }, |ctx| { … })`:

```rhai
scenario("happy path", #{ tags: ["smoke"] }, |ctx| { … });
scenario("nightly load", #{ tags: ["slow", "nightly"] }, |ctx| { … });
scenario("known broken", #{ skip: "needs fix" }, |ctx| { … });   // reported, not run
scenario("wip", #{ only: true }, |ctx| { … });                   // run only this one
```

- **Tags**`--tag smoke` runs only tagged scenarios; `--exclude-tag slow` drops
  them. Both are repeatable and comma-separated, and combine with `--scenario`.
- **Skip**`skip: true` / `skip: "reason"` disables a scenario statically; it is
  reported as skipped (not failed). For runtime/env-gated conditions, call
  `skip("reason")` inside `setup`/the body, e.g. `if env("STAGE") != "prod" { skip("prod only") }`.
- **Focus**`only: true` restricts the run to focused scenarios (with a warning,
  so a stray `only` can't silently hide the rest of the suite).

Skipped scenarios don't fail the run; the summary reports them (`… 1 skipped`).

Filters and `only` apply to scenarios inside a suite. A single-scenario file (one
with no `scenario(...)` call — its top-level code *is* the test) always runs; it has
no tags/`only` to match. Directory runs only pick up suite files, so this only
matters when you list single-scenario and suite files together on one command line
(there, the single-scenario files run first).

## Docker

A small image (~36 MB) with baresip compiled in — nothing to install, ideal for
CI. The release workflow builds and pushes it to GHCR on each `ringo-flow-v*`
tag, so just pull and run a scenario directory (mounted read-only):

```sh
docker run --rm --network host \
  -e SIP_DOMAIN=example.com -e A_USER=alice -e A_PASS=… -e B_USER=bob -e B_PASS=… \
  -v "$PWD/scenarios:/scn:ro" \
  ghcr.io/davidborzek/ringo-flow:latest run /scn --scenario "answered call"
```

Tags: `:latest` (the newest release) and `:<version>` to pin a specific one
(e.g. `:0.10.0`).

- **`--network host`** is the simplest way to get working SIP/RTP and to reach
  internal services — the container shares the host's network and DNS. On the
  default bridge network, SIP media and split-horizon/VPN DNS often don't work.
- **Credentials:** pass them as `-e VAR=…` for `env(...)`, or mount a dotenv file
  and add `--env-file /scn/dev.env`.
- **Recordings:** `--save-audio` writes to the working dir (`/work`); mount a
  writable volume there to keep them.

**TLS to a private/corporate CA.** If `http(...)` targets a service whose cert is
signed by an internal CA, give the container that trust store — reqwest uses
rustls + rustls-native-certs, which honors `SSL_CERT_FILE`:

```sh
docker run --rm --network host \
  -v /etc/ssl/certs/ca-certificates.crt:/ca.pem:ro -e SSL_CERT_FILE=/ca.pem \
  -v "$PWD/scenarios:/scn:ro" ghcr.io/davidborzek/ringo-flow:latest run /scn
```

Without it, such requests fail with *unable to get local issuer certificate*. As a
last resort, `--insecure-http` (or `RINGO_FLOW_INSECURE_HTTP=1`) skips certificate
verification entirely — only for throwaway dev testing.

To build the image yourself (for development):
`docker build -f crates/ringo-flow/Dockerfile -t ringo-flow .`

## Examples

[`examples/`](examples/) has runnable, commented scenarios:

- [`two-party.rhai`]examples/two-party.rhai — two agents place, answer and tear
  down a call.
- [`suite.rhai`]examples/suite.rhai — a suite with `setup`/`teardown` and an
  answered- vs rejected-call scenario.
- [`three-party-transfer.rhai`]examples/three-party-transfer.rhai — three
  agents and a blind **SIP REFER**: Callee transfers the Caller to a Target, who
  ends up connected while the Callee drops out.
- [`webhook-mock.rhai`]examples/webhook-mock.rhai — a **mock HTTP server**
  answers the API's webhook with call actions; the scenario waits
  for the webhook via `await_until` and asserts on the recorded request.

## API reference & editor support

The API is generated from the engine, so it never drifts from the code. The
canonical reference is [**docs/scenario-api.md**](docs/scenario-api.md):

```sh
ringo-flow docs docs/scenario-api.md                # Markdown reference (default)
ringo-flow docs ringo-flow-api.html --format html   # self-contained HTML page
ringo-flow definitions ringo-flow.d.rhai            # Rhai definition file (LSP)
```

Point the [Rhai language server](https://github.com/rhaiscript/lsp) at the
`.d.rhai` for completion, signatures and hover docs in your editor.

## Notes

- **`wait(n)` is a hold, not a sleep** — it fails if a call that's established at
  the start drops during it.
- **DTMF:** for reliable headless DTMF set `dtmf_mode: "info"` on the agent. (RTP
  telephone-event needs a clocked TX, which idles once a headless agent's audio
  goes silent, so only the first digit reaches the wire.)
- **Audio** is verified headless via baresip's `aubridge` + Goertzel tone
  detection; for a conference give each party a distinct tone and check each one
  hears the others.

## Security

Scenario files are **trusted code**, not sandboxed input: a scenario can make
arbitrary HTTP requests (`http(...)`) and read local files (`file(...)`,
`load_env(...)`). Only run scenarios you wrote or reviewed — and in CI, where the
runner has network reach and real credentials, keep scenario sources and env
files under the same review controls as the rest of your code.