Writing scenarios
A scenario is a JavaScript file (or TypeScript, transpiled — see below). The top level can be the whole test, or you can register several named scenarios as a suite.
Each file runs as an ES module, so you can import helper files and use
top-level await.
Agents and call control
new Agent(name, { … }) connects a headless baresip
instance and returns a handle you drive with verbs —
register,
dial,
accept,
hangup, hold, dtmf, transfer, … See
Agent for the full set, the config options and the
readable state (registered,
state, …).
until
SIP is asynchronous, so assertions are polled:
until re-runs an
expect(...) until it holds or a timeout elapses.
Use it instead of sleeping.
a.dial(b);
await until(() => expect(b.state).toBe(State.Ringing), "15s");
The matchers — toBe,
toBeTruthy,
toContain, … — are all on the
assertion handle, and are the Jest names, so they should already be familiar.
until resolves with the value the condition returned, which lets you bind a
verified value in one step:
const traceId = await until(() => expect(b.header("X-Trace-Id")).toBeDefined().value());
Because until yields the event loop while it polls, independent waiters can run
concurrently:
await Promise.all([callee.verifyAudio(440, "5s"), caller.verifyAudio(480, "5s")]);
Suites: setup / scenario / teardown
setup() runs before each scenario; each
scenario(name, body) runs in isolation with
fresh agents; teardown() runs after each (even
on failure).
Keep fixtures in closure-scoped variables: declare them once up top, assign
them in setup(), and read them everywhere. With a single @type annotation per
fixture, every body gets completion and type-checking, with no per-scenario
typing:
// @ts-check
/** @type {Agent} */ let caller;
/** @type {Agent} */ let callee;
setup(() => {
caller = new Agent("caller", { username: env("A_USER"), domain: env("SIP_DOMAIN"), password: env("A_PASS") });
callee = new Agent("callee", { username: env("B_USER"), domain: env("SIP_DOMAIN"), password: env("B_PASS") });
});
teardown(() => caller.hangup());
scenario("answered call", { tags: ["smoke"] }, async () => {
caller.dial(callee);
await until(() => expect(callee.state).toBe(State.Ringing), "15s");
callee.accept();
await until(() => expect(caller.state).toBe(State.Established), "10s");
});
setup()’s return value — if you return one — is passed to each scenario body
(and to teardown) as its first argument, which is handy when a fixture must be
built per scenario. It is typed any, so the closure-variable pattern above is
the one that keeps full type-checking.
Parametrised scenarios: scenario.each
To run the same body over a table of inputs, scenario.each(table) returns a
registration function: it registers one scenario per row and passes the row to the
body as a second argument. $key tokens in the name are replaced with that row’s
field, so each scenario gets a distinct — and individually selectable — name:
scenario.each([
{ kind: "internal", target: "201", within: "10s" },
{ kind: "external", target: "+4921112345", within: "20s" },
])("dial $kind target reaches ringing", { tags: ["dialplan"] }, async (ctx, p) => {
caller.dial(p.target);
await until(() => expect(caller.state).toBe(State.Ringing), p.within);
});
The options object is optional (scenario.each(table)(name, body) works too) and
applies to every row. Rows are registered in table order.
Type-checking a plain .js scenario
Generate the definitions once and point a jsconfig.json at them; with
// @ts-check at the top of each scenario, your editor — and tsc --noEmit in
CI — checks the whole DSL:
ringo-flow definitions --lang js ringo-flow.d.ts
// jsconfig.json
{
"compilerOptions": {
"checkJs": true,
"strict": true,
"noEmit": true,
"target": "es2022",
"module": "esnext",
"types": []
},
"files": ["ringo-flow.d.ts", "scenario.js"]
}
The .d.ts declares the whole DSL as ambient globals (scenario,
new Agent(...), expect, …), so a scenario needs no imports for them. A wrong
matcher, an unknown agent-config key or a forgotten await is reported before
baresip ever starts.
Writing scenarios in TypeScript
Scenarios execute as JavaScript, but you can author them in real TypeScript and
transpile to JS for ringo-flow run.
-
Generate the type definitions and point a
tsconfig.jsonat them:ringo-flow definitions --lang js ringo-flow.d.ts # writes the .d.ts next to your scenarios// tsconfig.json { "compilerOptions": { "strict": true, "noEmit": true, "target": "es2022", "module": "esnext", "types": [] }, "files": ["ringo-flow.d.ts", "scenario.ts"] } -
Write
scenario.ts. The globals and theAgent/MockServerclasses are typed, sotsc(or your editor) catches a wrong matcher, an unknown config key or a bad argument before baresip ever starts:scenario("answered call", async () => { const caller = new Agent("caller", { username: env("A_USER"), domain: env("SIP_DOMAIN") }); caller.register(); await until(() => expect(caller.registered).toBeTruthy(), "10s"); }); -
Type-check, transpile with Bun, and run the emitted JS:
tsc --noEmit # optional: fail on a type error bun build scenario.ts --outfile scenario.js # strip types → plain JS ringo-flow run scenario.js
bun build strips the type annotations and leaves the ambient globals untouched, so
the runtime executes a plain .js. Relative imports of helper .ts files are bundled
into the one output — or keep them as separate .js and let ringo-flow resolve the
imports at run time.
Selecting, tagging and skipping
The scenario(name, { … }, body) options
control which scenarios run:
- Tags —
{ tags: ["smoke"] }, then--tag smoke/--exclude-tag slow. - Skip —
{ skip: true }or{ skip: "reason" }disables a scenario statically; or callskip("reason")at runtime (e.g. env-gated). - Focus —
{ only: true }runs only the focused scenario(s), run-wide.
Skipped scenarios are reported but don’t fail the run.
More
- Assertions and matchers — the full matcher set.
- Audio testing — send tones/files and assert what’s received.
- HTTP & webhooks — call and mock a backend API.
- Rhai frontend — the deprecated frontend and how to migrate off it.