Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/ringo-flow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4"] }
reqwest = { version = "0.13", default-features = false, features = ["default-tls", "charset", "blocking"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
rhai = { version = "1", features = ["sync", "metadata", "internals"] }
8 changes: 8 additions & 0 deletions crates/ringo-flow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ the first failure. No sound hardware needed; it's built on the shared
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

Expand Down Expand Up @@ -136,6 +141,9 @@ To build the image yourself (for development):
- [`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

Expand Down
132 changes: 132 additions & 0 deletions crates/ringo-flow/docs/scenario-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ Tasks may share captured variables (each gets an independent snapshot,
so they can't race). Don't overlap `await_until` across tasks; its
silencing is global.

### `regex(pattern: string) -> PathPattern`

A regex path matcher for `respond`/`on`/`request_count`/… anchored to the
whole path: `regex("/calls/.*")` matches `/calls/123`. Errors on a bad
pattern.

### `scenario(name: string, body: Fn)`

Register a named scenario, run in isolation (fresh agents, torn down
Expand Down Expand Up @@ -306,6 +312,132 @@ The whole JSON body as a native value (object→map, array, …).
The value at a dotted JSON path (e.g. `"data.id"`), typed: object→map,
array, number, bool, `null`→`()`. Errors if the path is missing.

## HTTP mock server

### `get body(request: MockRequest) -> string`

The raw request body.

### `get method(request: MockRequest) -> string`

The request method (upper-case).

### `get path(request: MockRequest) -> string`

The request path.

### `get port(mock: HttpMock) -> int`

The port the server is listening on.

### `get url(mock: HttpMock) -> string`

The server's base URL, e.g. `http://127.0.0.1:8080`.

### `header(request: MockRequest, name: string) -> ?`

A request header value (case-insensitive), or `()` if absent.

### `json(request: MockRequest, path: string) -> ?`

The value at a dotted JSON path in the body (object→map, array, number,
bool, `null`→`()`). Errors if the path is missing.

### `json_response(body) -> map`

Build a `200 application/json` response map from `body` (JSON-encoded),
for `respond`/`on`. `body` may be a map or an array, e.g.
`json_response(#{ actions: [ … ] })` or `json_response([ … ])`.

### `last_request(mock: HttpMock, path: PathPattern) -> MockRequest`

The most recent request on a `regex(...)` path (errors if none yet).

### `last_request(mock: HttpMock, path: string) -> MockRequest`

The most recent request on `path` (errors if none yet). Read it after
`await_until` confirms the webhook arrived.

### `mock_server() -> HttpMock`

Start a mock HTTP server on a free port and return a handle. Stopped
automatically at the end of the scenario. Use `url` to point the system
under test at it, `respond`/`on` to define routes.

### `mock_server(config: map) -> HttpMock`

Start a mock HTTP server with config `#{ port: 8080 }` (omit `port` for a
free one). Returns a handle; stopped automatically at scenario end.

### `on(mock: HttpMock, method: string, path: PathPattern, responder: Fn)`

Dynamic responder for `method` and a `regex(...)` path.

### `on(mock: HttpMock, method: string, path: string, responder: Fn)`

Answer `method path` dynamically: the `|req|` closure receives the
`MockRequest` and returns a response map (e.g. `json_response(#{…})`).
`method` may be `"*"` for any method. The closure runs on a runtime
worker, so keep it pure (request → response): no agent verbs, no `wait`
— those block a worker thread.

### `on(mock: HttpMock, path: PathPattern, responder: Fn)`

Dynamic responder for a `regex(...)` path on any HTTP method.

### `on(mock: HttpMock, path: string, responder: Fn)`

Dynamic responder for `path` on any HTTP method.

### `query(request: MockRequest, name: string) -> ?`

A query-string parameter value, or `()` if absent.

### `request_count(mock: HttpMock, path: PathPattern) -> int`

How many requests arrived on a `regex(...)` path (any method).

### `request_count(mock: HttpMock, path: string) -> int`

How many requests arrived on `path` (any method). Poll it with
`await_until`, e.g.
`await_until(|| assert(hooks.request_count("/voice")).equals(1))`.

### `requests(mock: HttpMock, path: PathPattern) -> array`

All requests on a `regex(...)` path, in arrival order, as `MockRequest`s.

### `requests(mock: HttpMock, path: string) -> array`

All requests received on `path`, in arrival order, as `MockRequest`s.

### `respond(mock: HttpMock, method: string, path: PathPattern, response: map)`

Static response for `method` and a `regex(...)` path.

### `respond(mock: HttpMock, method: string, path: string, response: map)`

Register a static response for `method path`: a map
`#{ status: 200, content_type: "…", headers: #{…}, body: <string|map> }`
(use `json_response`/`text_response` to build it). `method` may be `"*"`
for any method. Re-register to stage the next answer between webhooks.

### `respond(mock: HttpMock, path: PathPattern, response: map)`

Static response for a `regex(...)` path on any HTTP method.

### `respond(mock: HttpMock, path: string, response: map)`

Static response for `path` on any HTTP method.

### `stop(mock: HttpMock)`

Stop the server now (it otherwise stops automatically at scenario end).

### `text_response(body: string) -> map`

Build a `200 text/plain` response map from `body`, for `respond`/`on`.

## Audio

### `file(path: string) -> AudioSpec`
Expand Down
60 changes: 60 additions & 0 deletions crates/ringo-flow/examples/webhook-mock.rhai
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Webhook-driven API test with the built-in HTTP mock server.
//
// The pattern: a telephony API calls our webhook for a call and we
// answer with the actions it should perform. We stand up a mock server, point the
// API at it, place a call, and assert the API hit our webhook as expected. The
// mock server is stopped automatically at the end of the scenario.
//
// Environment:
// SIP_DOMAIN the SIP domain the caller registers to
// A_USER A_PASS the calling account
// API_URL base URL of the API under test (receives the config call)
// API_NUMBER the number that routes into the API
//
// Run:
// SIP_DOMAIN=… A_USER=… A_PASS=… API_URL=… API_NUMBER=… \
// ringo-flow run crates/ringo-flow/examples/webhook-mock.rhai

let dom = env("SIP_DOMAIN");

// Start the mock on a free port; `hooks.url` is the base URL to hand to the API.
let hooks = mock_server();

// Dynamic responder: the API posts a webhook, we answer with the call actions.
// The closure must be pure (request in, response out) — no agent verbs here.
hooks.on("POST", "/voice", |req| {
if req.json("event") == "incoming_call" {
json_response(#{ actions: [
#{ type: "answer" },
#{ type: "play", url: "https://example.com/greeting.wav" },
] })
} else {
json_response(#{ actions: [ #{ type: "hangup" } ] })
}
});

// Routes can match a regex path and/or any HTTP method:
// - regex(...) matches per-call status callbacks like /calls/<id>/status
// - omitting the method (3 args → 2 args) matches any method
hooks.on(regex("/calls/.*/status"), |req| text_response("ok"));

// Tell the API under test where to send its webhooks.
http("PUT", env("API_URL") + "/config?webhook=" + hooks.url + "/voice");

let a = agent("A", #{ username: env("A_USER"), domain: dom, password: env("A_PASS") });
a.register();
await_until(|| assert(a.registered).is_true(), "10s");

// Place the call into the API; it should call our webhook back.
a.dial(env("API_NUMBER"));

// Wait for the webhook via the same await_until used everywhere else.
await_until(|| assert(hooks.request_count("/voice")).equals(1), "10s");

// Inspect the recorded request.
let req = hooks.last_request("/voice");
assert(req.json("event")).equals("incoming_call");
assert(req.header("content-type")).contains("application/json");

a.hangup();
await_until(|| assert(a.state).equals(State::Idle), "10s");
Loading
Loading