This is the full developer documentation for Axx
# Let agents build it. Prove that it works.
> Axx is an open-source acceptance testing framework that verifies real software against executable behavioral expectations, independent of the language, architecture or agent that built it.
Define what correct means. Let implementations evolve. Verify the behavior that matters.
## Generating software is getting easier. Trusting it isn’t.
[Section titled “Generating software is getting easier. Trusting it isn’t.”](#generating-software-is-getting-easier-trusting-it-isnt)
Coding agents are changing how quickly software can be created and changed. But generating an implementation and knowing that it meets its requirements are different problems.
Code review, unit tests and static analysis remain valuable. None of them shows that the running system delivers the behavior expected of it. As software factories become more autonomous, verifying that behavior independently matters more, not less.
**The challenge isn’t generating more software. It’s changing software continuously without losing confidence in what it does.**
## Keep what correct means apart from how it’s built.
[Section titled “Keep what correct means apart from how it’s built.”](#keep-what-correct-means-apart-from-how-its-built)
### Intent
Software exists to meet requirements, not to run code. Acceptance criteria state the behaviors a system must provide, as scenarios that engineers, product owners and agents can all review, and that stay meaningful while the implementation changes.
[How steps are designed →](/next/explanations/step-design/)
### Freedom of implementation
Refactor a service, switch frameworks, replace a component or rewrite it in another language. While the external contract holds, the acceptance criteria still apply: axx never loads your code.
[Black-box testing →](/next/explanations/black-box-testing/)
### Independent verification
Axx runs each scenario against the running system, through its APIs, events, data stores and user interfaces. An agent can use axx to check its own work, but the code it wrote isn’t what defines success. The acceptance criteria are, and your team decides who approves them.
[How axx works →](/next/explanations/how-axx-works/)
## Give agents freedom to build. Keep control of what correct means.
[Section titled “Give agents freedom to build. Keep control of what correct means.”](#give-agents-freedom-to-build-keep-control-of-what-correct-means)
1. ### Define behavior
Write the acceptance criteria: what the system must accomplish, as scenarios made of real steps.
2. ### Implement freely
Engineers and coding agents build, change, refactor or replace the implementation.
3. ### Verify independently
Axx starts the system, exercises it through its observable interfaces and checks the expected outcomes.
4. ### Iterate with evidence
Failures come back as structured data, expected against actual, so the next change fixes the code, not the requirement.
Axx is the verification layer of a software factory, not the factory: it doesn’t orchestrate agents or write code. It runs wherever the software is built, from a developer’s terminal to a CI pipeline to an agent’s tool calls.
## Built for real systems. Built for autonomous engineering.
[Section titled “Built for real systems. Built for autonomous engineering.”](#built-for-real-systems-built-for-autonomous-engineering)
### Black-box by architecture
Scenarios exercise running systems through the boundaries their clients use: HTTP and gRPC, events, databases, files, browsers and apps. They never import a class or depend on a framework, so they outlive refactors and rewrites.
[Black-box testing →](/next/explanations/black-box-testing/)
### One scenario across the system
Send a request, check the call your service made to a mocked dependency, then the rows it stored and the event it published, all in one scenario, with steps from axx’s packs and no glue code.
[See a scenario →](/next/explanations/why-axx/)
### Native interfaces for coding agents
Agents search the steps actually installed, validate scenarios without starting anything, work through axx’s MCP server, and read failures as structured data, instead of guessing at a test API or parsing logs.
[Axx for agents →](/next/explanations/axx-for-agents/)
### Fast, repeatable feedback
`axx run` starts your services, waits until they’re healthy, runs scenarios in parallel and cleans up. Generated fixtures, isolation checks, polling steps and reruns of what failed keep each cycle quick and reliable.
[Parallel runs →](/next/guides/parallel-runs/)
### Verification beyond traditional applications
Test AI features, MCP servers, A2A agents, mobile and desktop apps, and the models apps run on the device, replaced by stubs axx builds in the model’s own format, whose answers are your mappings.
[Stub embedded models →](/next/guides/stub-embedded-models/)
## Acceptance criteria you can execute.
[Section titled “Acceptance criteria you can execute.”](#acceptance-criteria-you-can-execute)
This scenario registers a parcel through a service’s REST API. In one run, axx checks the request and the response against the service’s OpenAPI document, checks that the service asked its address service (a WireMock mock) about the postcode, and checks what it stored in PostgreSQL, down to a JSON column.
features/register-parcels.feature
```gherkin
Feature: Register parcels
Background:
Given the parcels service with the following properties:
| url | http://localhost:8400 |
| openapi | http://localhost:8400/openapi.json |
And the mocked addresses service with the following properties:
| url | http://localhost:8081 |
And a parcels-db database with the following properties:
| url | postgres://localhost:5432/parcels |
| user | parcels |
| password | parcels |
Scenario: A shop registers a parcel
Given a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload properties are:
| reference | PX-REG-1001 |
| recipient.postcode | "53111" |
When the request is executed
Then the response status code is 201
And the mocked GET request to /v1/postcodes/DE/53111 named postcode-check was received by addresses
And a selection of rows is retrieved from the parcels.parcels table where:
| reference | PX-REG-1001 |
And the 1st row details property for the selection json properties are:
| source | api |
| zone | DE-1 |
```
Nothing in it names a class, a framework or a language. It describes what the service does at its boundaries, so the same scenario verifies a refactor, a new framework or a rewrite. Every line is a step from one of axx’s packs: the [step index](/next/references/step-index/) lists them all, and the [parcels example](https://github.com/nimbusxr/axx/tree/main/examples/parcels) runs a full suite like it.
## Go beyond conventional acceptance testing.
[Section titled “Go beyond conventional acceptance testing.”](#go-beyond-conventional-acceptance-testing)
### Deterministic AI testing
Mock the model APIs your service calls (OpenAI’s and the servers that speak it, Anthropic’s, Gemini’s) and check what it sent them. For the models an app runs itself, axx builds stubs in their native formats, from ONNX and Core ML to TFLite and GGUF, and stands in for the platform’s own: Apple’s Vision, Android’s ML Kit, a browser’s built-in AI.
[Test AI features →](/next/guides/test-ai-features/)
### Agent protocol testing
Call an MCP server’s tools as an AI assistant does, over stdio or HTTP, with the tools’ own schemas as the contract. Message [A2A agents](/next/guides/test-a2a/) over JSON-RPC, HTTP+JSON or gRPC and check their tasks and artifacts. Mock the servers and agents your service depends on.
[Test MCP servers →](/next/guides/test-mcp/)
### Native application verification
Drive Android and iOS apps on emulators and simulators, and [macOS, Windows and Linux apps](/next/guides/test-desktop-apps/), by the names people see. Move the phone along a route, tap its notifications, and show a simulator’s or an emulator’s camera a photo, a video or a 3D scene, which the app’s AR (ARKit, ARCore) finds where it is.
[Test mobile apps →](/next/guides/test-mobile-apps/)
### Distributed workflows
Publish and consume Kafka events with Avro and Schema Registry, seed and query SQL and MongoDB, mock dependencies, check [cloud services](/next/guides/test-cloud-services/) like S3, Pub/Sub and Service Bus, and validate against OpenAPI and AsyncAPI contracts, in the same scenario.
[Test Kafka with Avro →](/next/guides/test-kafka-avro/)
## Designed for engineers. Ready for agents.
[Section titled “Designed for engineers. Ready for agents.”](#designed-for-engineers-ready-for-agents)
Axx comes from years of maintaining acceptance suites for complex enterprise systems, where executable acceptance criteria shaped how teams built and shipped software. Its architecture reflects those lessons: black-box scenarios, step text that never changes, and test data that can’t collide between parallel scenarios. It wasn’t built to make Gherkin work with AI.
Engineers use it directly: one binary, feature files, `axx run`. Coding agents use the same tool, with [interfaces made for them](/next/tutorials/with-an-agent/).
Axx publishes how coding agents do with it. In its [agent evaluation](/next/explanations/agent-evals/), an agent writes the tests for a parcels service. A suite succeeds only if it passes on the working service and fails on every bug planted in it.
* **With axx:** all 72 suites succeeded, across four setups (with its skills, its MCP server, both or neither).
* **Without axx:** 14 of 18 did.
It’s a small sample, run by the project itself: one model (`openai/gpt-6-luna`), six tasks, three tries each. The tasks, planted bugs, checks and transcripts are all [public](https://github.com/nimbusxr/axx-evals).
## Build faster. Keep the contract.
[Section titled “Build faster. Keep the contract.”](#build-faster-keep-the-contract)
Whether software is written by an engineer, generated by an agent or rebuilt from scratch, its required behavior still matters. Axx verifies that behavior against the running system.
[Start verifying](/next/tutorials/quickstart/)[Explore the documentation](/next/explanations/why-axx/)
Axx is open source (Apache-2.0) and in beta (`v0.x`): interfaces may change before `v1.0.0`.
**For coding agents:** in a project, the axx command line answers faster than this site: `axx steps` lists every step of the project’s packs, a line each, and `axx steps show ` gives one step’s documentation and examples. On this site, every page has a Markdown twin (add `.md` to its path), [llms.txt](/next/llms.txt) lists them, and a pack’s steps are at `/references/packs/.md`.
# Why Axx
> Axx verifies running software against executable acceptance criteria, black-box, so implementations can change without losing the definition of correct. It's the behavioral verification layer for agentic software factories.
Software is increasingly produced by coding agents, but the responsibility for correctness hasn’t gone away.
As implementations become easier to generate and replace, engineering teams need durable, independently executable definitions of expected behavior. A test suite that imports the code it checks changes with that code; a definition of correct shouldn’t.
Axx approaches this through black-box acceptance testing. It verifies running systems through their observable interfaces (their HTTP APIs and web apps, their databases, their event streams and the dependencies they call), so implementations can evolve without the acceptance criteria being tied to their internal structure.
The result is a verification framework for conventional engineering workflows and for the emerging generation of agentic software factories, where agents build and change the implementation and Axx checks that it still does what it must.
Three ideas run through it:
* **Intent.** Acceptance criteria state what the system must do, as scenarios people and agents can review. They’re written in Gherkin, and they stay meaningful while the implementation changes.
* **Freedom of implementation.** Refactor a service, switch frameworks or rewrite it in another language: while the external contract holds, the same scenarios apply.
* **Independent verification.** An agent can use Axx to check its own work, but the code it wrote isn’t what defines success. The scenarios are, and your team decides who approves them.
When a scenario passes, the behavior it describes works in the running system, not in a mock of it or in one unit on its own. Dependencies outside the system can be mocked (Axx checks the calls made to them), while the system itself runs for real.
## One scenario covers the whole flow
[Section titled “One scenario covers the whole flow”](#one-scenario-covers-the-whole-flow)
```gherkin
Feature: Register parcels
Background:
Given the parcels service with the following properties:
| url | http://localhost:8400 |
| openapi | http://localhost:8400/openapi.json |
And the mocked addresses service with the following properties:
| url | http://localhost:8081 |
And a parcels-db database with the following properties:
| url | postgres://localhost:5432/parcels |
| user | parcels |
| password | parcels |
Scenario: A shop registers a parcel
Given a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload properties are:
| reference | PX-REG-1001 |
| recipient.postcode | "53111" |
When the request is executed
Then the response status code is 201
And the mocked GET request to /v1/postcodes/DE/53111 named postcode-check was received by addresses
And a selection of rows is retrieved from the parcels.parcels table where:
| reference | PX-REG-1001 |
And the 1st row details property for the selection json properties are:
| source | api |
| zone | DE-1 |
```
This one scenario:
* builds a request from the service’s own OpenAPI example and sends it;
* checks the request and the response against that OpenAPI document;
* checks that the service asked its downstream address service (mocked) about the postcode;
* checks what the service stored in PostgreSQL, down to a JSON column.
Every line is a step from one of Axx’s packs, so there is no step code to write.
## Test the service, not the code
[Section titled “Test the service, not the code”](#test-the-service-not-the-code)
Axx never loads your code. The service can be written in any language, and the tests keep passing through refactors and rewrites because they only depend on what the service does. The people who wrote the acceptance criteria can read them too. See [Black-box testing](/next/explanations/black-box-testing/).
Axx’s packs cover:
| Area | What you get |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| REST | Requests and responses validated against your OpenAPI document |
| Mocked dependencies | Calls to them verified through WireMock |
| SQL | PostgreSQL, MySQL, SQLite and SQL Server: seeds, selections, JSON columns, locks and triggers |
| MongoDB | Seeding and querying |
| Kafka | Publishing and consuming events, with Avro and Schema Registry |
| Logs | The entries your services log, in files or sent over UDP, TCP or HTTP |
| Files | The files your services write: their content, their text (PDF, Word, Excel…) and their rows |
| Web apps | Real browsers through Playwright, with screenshots, accessibility audits, network control, Lighthouse scores and JavaScript coverage |
| Clouds | S3, SQS, SNS, EventBridge and DynamoDB; Cloud Storage, Pub/Sub, BigQuery and Firestore; Blob Storage and Service Bus |
## One binary with nothing to wire up
[Section titled “One binary with nothing to wire up”](#one-binary-with-nothing-to-wire-up)
Axx needs no JVM, no build plugin and no test-runner glue. `axx run` does the whole job:
1. It starts the services listed in `axx.yaml` and waits until they are healthy.
2. It runs the scenarios in parallel.
3. It stops everything and cleans up, even after a crash or Ctrl-C.
`axx up` keeps the system running between runs, so each edit-and-run cycle takes seconds.
It is fast. The [example suite](https://github.com/nimbusxr/axx/tree/main/examples/parcels) has 101 scenarios and 888 steps, covering REST, WireMock, PostgreSQL, MongoDB, Kafka, logs, files and a web app in real browsers. Against a running stack, its 38 scenarios that need no browser finish in about a second.
## Axx’s steps handle the hard parts
[Section titled “Axx’s steps handle the hard parts”](#axxs-steps-handle-the-hard-parts)
The steps of Axx’s packs do the difficult work:
* OpenAPI validation
* database seeds and JSONB queries
* Avro with Schema Registry
* waiting for events to arrive
* checking calls to mocked services
All of them compose the same way. You build something (a request, an event, a selection), act on it, and assert on what came back. A step can name the service it targets (`on parcels-db`) when a scenario uses more than one. Ordinals such as `the 2nd selection` refer back to earlier ones. Once you know the pattern, a pack you have never used reads the same.
Step text is public API, and it never changes, so a feature file keeps working across every release.
## Extend it your way
[Section titled “Extend it your way”](#extend-it-your-way)
Steps come in **packs**. A project uses only the packs it needs (`axx pack add rest sql`).
When Axx doesn’t cover something, write a pack of your own in Go. Your steps work on the same scenario context as Axx’s:
* the services the scenario registered
* its requests and responses
* its database selections
* its events
Use custom packs to reach a system Axx doesn’t cover, or to write scenarios in your team’s own words. `axx pack new ./steps` creates a pack. The first run builds it into Axx and caches the result. After that, your steps appear in `axx steps search`, `axx validate` and the agent skills like any other step. See [Write custom steps](/next/guides/write-custom-steps/).
## Built for coding agents
[Section titled “Built for coding agents”](#built-for-coding-agents)
Axx is designed so coding agents never have to guess. They search the real step text instead of inventing it, check every line without starting anything, get failures as data with the expected and actual values, and read docs generated from the binary they are running. [Axx for agents](/next/explanations/axx-for-agents/) explains how.
## Test data that doesn’t collide
[Section titled “Test data that doesn’t collide”](#test-data-that-doesnt-collide)
Parallel scenarios only stay reliable if each one uses its own data. `axx lint` finds colliding ids and keys before they cause flaky failures. `axx fixtures` generates test data checked against your schemas and reports when it drifts. See [Isolate test data](/next/guides/isolate-test-data/) and [Fixture factories](/next/guides/fixture-factories/).
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
| If you want to | Go to |
| ---------------------------------------------- | -------------------------------------------------- |
| Get a passing scenario in about ten minutes | [Quickstart](/next/tutorials/quickstart/) |
| Get a task done | [Guides](/next/guides/install/) |
| Look up a pack, a step, a command or a setting | [References](/next/references/packs/) |
| Understand the design | [How Axx works](/next/explanations/how-axx-works/) |
Axx is pre-release (`v0.x`): interfaces may change before `v1.0.0`.
# Your first suite
> Build an acceptance suite for a realistic service step by step - its API and OpenAPI contract, a dependency it calls, and its database.
In this tutorial, we’ll write an acceptance suite for Parcels, an example service that comes with Axx. Parcels is a parcel-delivery service with a PostgreSQL database. Shops ask it for price quotes and register their parcels with it, and it checks every address with an address service, which the example replaces with a mock.
Step by step, our suite will:
* call the service’s API and check its answers;
* check every request and response against the API’s OpenAPI document;
* check the call the service makes to the address service;
* check what the service writes to its database.
You’ll need Docker with Compose v2, Git, and Axx (see the [Quickstart](/next/tutorials/quickstart/)). Plan on about 30 minutes, including a few minutes for Docker to build images the first time.
## Get the example
[Section titled “Get the example”](#get-the-example)
```sh
git clone https://github.com/nimbusxr/axx.git
cd axx/examples/parcels
```
The `infra` directory holds a Docker Compose file that runs Parcels with everything it needs.
## Create the suite
[Section titled “Create the suite”](#create-the-suite)
Make a directory for our suite:
```sh
mkdir my-suite
cd my-suite
```
Create `axx.yaml` in it:
axx.yaml
```yaml
version: 1
services:
parcels:
dir: ../infra
command: docker compose up --build
ready:
http:
url: http://localhost:8400/health
timeout: 10m
cleanup: docker compose down -v --remove-orphans
```
This tells Axx to start Parcels with Docker Compose, to wait until its health check answers, and to remove the containers when it stops.
Next, choose the packs of steps the suite uses. We’ll send requests to Parcels with `rest`, check what it asked the address service’s mock with `mock`, and look in its database with `sql`:
```sh
axx pack add rest mock sql
```
```console
created axx-packs.yaml
added rest, mock, sql
```
## Start Parcels
[Section titled “Start Parcels”](#start-parcels)
```sh
axx up
```
```console
axx: starting services in the background (logs: .axx/logs)
up: parcels (stop with `axx down`)
```
The first time, Docker builds the images, so this takes a few minutes. Parcels now keeps running in the background, and every run in this tutorial takes a fraction of a second.
## Ask for a price
[Section titled “Ask for a price”](#ask-for-a-price)
Create a `features` directory:
```sh
mkdir features
```
Then create `features/quotes/quotes.feature`:
features/quotes/quotes.feature
```gherkin
Feature: Quotes
Background:
Given the parcels service with the following properties:
| url | http://localhost:8400 |
Scenario: A shop asks for a price
Given a POST request to /api/quotes
And a request payload using an application/json empty content template
And the request payload properties are:
| weightGrams | 1200 |
| recipient | {"postcode": "10115", "country": "DE"} |
When the request is executed
Then the response status code is 200
And the response payload property priceCents is '690'
```
The scenario builds a request body from an empty JSON object: a 1.2 kg parcel to a Berlin postcode. Run it:
```sh
axx run
```
```console
axx: preparing rest, mock, sql (once; cached for later runs)
axx: ready in 31s
axx: reusing services started by `axx up`: parcels
warning: services started by `axx up` keep the data of earlier runs; `axx down`, then `axx up`, starts them afresh
Feature: Quotes
Scenario: A shop asks for a price # features/quotes/quotes.feature:7
✓ Given the parcels service with the following properties: (background)
| url | http://localhost:8400 |
✓ Given a POST request to /api/quotes
✓ And a request payload using an application/json empty content template
✓ And the request payload properties are:
| weightGrams | 1200 |
| recipient | {"postcode": "10115", "country": "DE"} |
✓ When the request is executed (213ms)
log: POST http://localhost:8400/api/quotes -> 200 OK (213ms, 112 bytes)
attachment: request body (application/json, 68 B)
{"weightGrams":1200,"recipient":{"postcode":"10115","country":"DE"}}
attachment: response body (application/json, 112 B)
{"zone":"DE-1","serviceLevel":"STANDARD","weightGrams":1200,"priceCents":690,"currency":"EUR","deliveryDays":2}
✓ Then the response status code is 200
✓ And the response payload property priceCents is '690'
1 scenario (1 passed)
7 steps (7 passed)
Finished in 213ms
```
Notice the first lines. Axx prepared itself with the suite’s packs, which it does only once. And it didn’t start anything this time: it used the Parcels service that `axx up` started. The price is 6.90 EUR, in cents.
## Check the API contract
[Section titled “Check the API contract”](#check-the-api-contract)
Parcels publishes an OpenAPI document that describes its API. Add it to the service in the `Background`:
features/quotes/quotes.feature
```gherkin
Background:
Given the parcels service with the following properties:
| url | http://localhost:8400 |
| openapi | http://localhost:8400/openapi.json |
```
Run `axx run` again. The scenario still passes. Axx now checks every request and response against that document, so the suite notices when Parcels breaks its contract. We’ll see the check at work in a moment.
## Start from the contract’s example
[Section titled “Start from the contract’s example”](#start-from-the-contracts-example)
The OpenAPI document also gives an example request for each operation. Add a second scenario at the end of `features/quotes/quotes.feature`:
features/quotes/quotes.feature
```gherkin
Scenario: Heavier parcels cost more
Given a POST request to /api/quotes
And a request payload using an application/json content example
And the request payload property weightGrams is '4500'
When the request is executed
Then the response status code is 200
And the response payload properties are:
| weightGrams | 4500 |
| priceCents | 690 |
```
Run `axx run`. Both scenarios pass. Look at the new scenario’s output:
```console
Scenario: Heavier parcels cost more # features/quotes/quotes.feature:18
✓ Given the parcels service with the following properties: (background)
| url | http://localhost:8400 |
| openapi | http://localhost:8400/openapi.json |
✓ Given a POST request to /api/quotes
✓ And a request payload using an application/json content example
✓ And the request payload property weightGrams is '4500'
✓ When the request is executed
log: POST http://localhost:8400/api/quotes -> 200 OK (3ms, 112 bytes)
attachment: request body (application/json, 94 B)
{"weightGrams":4500,"serviceLevel":"STANDARD","recipient":{"postcode":"10115","country":"DE"}}
attachment: response body (application/json, 112 B)
{"zone":"DE-1","serviceLevel":"STANDARD","weightGrams":4500,"priceCents":690,"currency":"EUR","deliveryDays":2}
✓ Then the response status code is 200
✓ And the response payload properties are:
| weightGrams | 4500 |
| priceCents | 690 |
```
Notice the request body. We only wrote the weight. Axx started from the example request in the OpenAPI document, which also has a service level and a recipient, and changed the weight.
## Break the contract on purpose
[Section titled “Break the contract on purpose”](#break-the-contract-on-purpose)
In the new scenario, change the weight to a string:
```gherkin
And the request payload property weightGrams is '"heavy"'
```
Run `axx run`:
```console
✗ When the request is executed
log: POST http://localhost:8400/api/quotes -> 400 Bad Request (1ms, 125 bytes)
attachment: request body (application/json, 97 B)
{"weightGrams":"heavy","serviceLevel":"STANDARD","recipient":{"postcode":"10115","country":"DE"}}
attachment: response body (application/problem+json, 125 B)
{"detail":"weightGrams must be an integer","instance":"/api/quotes","status":400,"title":"Bad Request","type":"about:blank"}
OpenAPI validation failed for POST http://localhost:8400/api/quotes (status 400):
- validation.request.body.schema.type: $.weightGrams: got string, want integer (POST request body for '/api/quotes' failed to validate schema)
To relax a check, set its key (or a parent key) to WARN, INFO or IGNORE with "Given the OpenAPI validation levels are:" or openapi.levels in axx.yaml.
↷ Then the response status code is 200
↷ And the response payload properties are:
| weightGrams | 4500 |
| priceCents | 690 |
```
The double quotes make the value a string. The contract says `weightGrams` is an integer, and Axx fails the request step with the reason: `$.weightGrams: got string, want integer`. The steps after it are skipped.
Change the value back to `'4500'` and run `axx run` again. Both scenarios pass.
## Check what Parcels asked the address service
[Section titled “Check what Parcels asked the address service”](#check-what-parcels-asked-the-address-service)
To price a parcel, Parcels asks the address service which delivery zone serves the recipient’s postcode. In this example, the address service is a mock running on port 8081. Let’s check that the call happened.
Register the mock in the `Background`, and add a last step to the first scenario. Your file now looks like this:
features/quotes/quotes.feature
```gherkin
Feature: Quotes
Background:
Given the parcels service with the following properties:
| url | http://localhost:8400 |
| openapi | http://localhost:8400/openapi.json |
And the mocked addresses service with the following properties:
| url | http://localhost:8081 |
Scenario: A shop asks for a price
Given a POST request to /api/quotes
And a request payload using an application/json empty content template
And the request payload properties are:
| weightGrams | 1200 |
| recipient | {"postcode": "10115", "country": "DE"} |
When the request is executed
Then the response status code is 200
And the response payload property priceCents is '690'
And the mocked GET request to /v1/postcodes/DE/10115 named zone-lookup was received by addresses
Scenario: Heavier parcels cost more
Given a POST request to /api/quotes
And a request payload using an application/json content example
And the request payload property weightGrams is '4500'
When the request is executed
Then the response status code is 200
And the response payload properties are:
| weightGrams | 4500 |
| priceCents | 690 |
```
Run `axx run`. Both scenarios pass:
```console
✓ Then the response status code is 200
✓ And the response payload property priceCents is '690'
✓ And the mocked GET request to /v1/postcodes/DE/10115 named zone-lookup was received by addresses
```
Axx asked the mock which requests it had received, and found Parcels’ `GET /v1/postcodes/DE/10115`.
## Check the database
[Section titled “Check the database”](#check-the-database)
Now we’ll register a parcel through the API and check that Parcels stored it. Create `features/parcels/parcels.feature`:
features/parcels/parcels.feature
```gherkin
Feature: Parcels
Background:
Given the parcels service with the following properties:
| url | http://localhost:8400 |
| openapi | http://localhost:8400/openapi.json |
And a parcels-db database with the following properties:
| url | postgres://localhost:5432/parcels |
| user | parcels |
| password | parcels |
Scenario: A registered parcel is stored
Given a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload properties are:
| reference | PX-TUTORIAL-1 |
| sender | my-shop |
When the request is executed
Then the response status code is 201
And a selection of rows is retrieved from the parcels.parcels table where:
| reference | PX-TUTORIAL-1 |
| sender | my-shop |
And the selection has 1 row
```
The `Background` now also registers Parcels’ database. Run `axx run`, and look at the end of the output:
```console
✓ Then the response status code is 201
✓ And a selection of rows is retrieved from the parcels.parcels table where:
| reference | PX-TUTORIAL-1 |
| sender | my-shop |
✓ And the selection has 1 row
3 scenarios (3 passed)
26 steps (26 passed)
Finished in 22ms
```
The new scenario registered a parcel through the API. Then it selected the rows of the `parcels.parcels` table with that reference and sender, and found exactly one: Parcels stored what it was sent.
Run `axx run` once more. This time the new scenario fails:
```console
✓ When the request is executed
log: POST http://localhost:8400/api/parcels -> 409 Conflict (1ms, 135 bytes)
attachment: request body (application/json, 211 B)
{"reference":"PX-TUTORIAL-1","sender":"my-shop","weightGrams":1200,"serviceLevel":"STANDARD","recipient":{"name":"Ada Lovelace","street":"Invalidenstrasse 116","city":"Berlin","postcode":"10115","country":"DE"}}
attachment: response body (application/problem+json, 135 B)
{"detail":"parcel PX-TUTORIAL-1 is already registered","instance":"/api/parcels","status":409,"title":"Conflict","type":"about:blank"}
✗ Then the response status code is 201
Expected status code <201> but was <409>.
expected: 201
actual: 409
```
The parcel from the first run is still in the database, and references are unique. Acceptance tests run against the service’s real data, so data a scenario creates stays behind. [Isolate test data](/next/guides/isolate-test-data/) shows how suites deal with that. For now, stopping Parcels clears everything.
## Stop Parcels
[Section titled “Stop Parcels”](#stop-parcels)
```sh
axx down
```
```console
stopped: parcels
```
Axx stopped Parcels and ran its cleanup, which removed the containers and their data. The next `axx up` starts from empty databases.
## What you’ve done
[Section titled “What you’ve done”](#what-youve-done)
You wrote a suite for a service with a database and a dependency, and checked four things from the outside:
* the answers of its API;
* its requests and responses against its OpenAPI contract;
* the call it makes to another service;
* what it writes to its database.
You didn’t write any test code: every line is a step Axx provides. The complete suite for Parcels, with events, a document store and more, is in `examples/parcels/acceptance`.
Next, in [Test with an agent](/next/tutorials/with-an-agent/), we’ll hand an acceptance criterion to a coding agent and review the scenario it writes.
# Quickstart
> Install Axx and run your first passing scenario against a small web server on your machine, in about ten minutes.
In this tutorial, we’ll install Axx, create a project, and write a scenario that tests a small web server running on your machine. We’ll run it and watch it pass, then break it on purpose to see what a failure looks like.
It takes about ten minutes. You’ll need a terminal, [Go](https://go.dev/dl/) 1.27 or newer to install Axx, and Python 3, which we’ll use as the web server.
## Install Axx
[Section titled “Install Axx”](#install-axx)
Install Axx with Go:
```sh
go install github.com/nimbusxr/axx/cmd/axx@latest
```
Check that your shell finds it:
```sh
axx version
```
Axx prints its version. If your shell says `command not found`, add Go’s `bin` directory to your `PATH` (`export PATH="$PATH:$(go env GOPATH)/bin"`) and try again.
## Create a project
[Section titled “Create a project”](#create-a-project)
Make a directory for the project and let Axx set it up:
```sh
mkdir hello-axx
cd hello-axx
axx init
```
```console
axx: preparing rest (once; cached for later runs)
axx: ready in 31s
create axx.yaml
create axx-packs.yaml
create features/smoke/smoke.feature
create .github/workflows/acceptance.yml
create AGENTS.md
create .gitignore
create .gitattributes
create .agents/skills (5 skills)
Codex keeps its MCP servers in ~/.codex/config.toml; `axx mcp install --agent codex --scope user` adds axx there.
next: put your service's command, port and health check in axx.yaml and the smoke feature, then `axx doctor` and `axx run`
```
Axx created a configuration file, `axx.yaml`, and a first feature file, `features/smoke/smoke.feature`. We’ll change both. It also created `axx-packs.yaml`, the list of the packs of steps the project uses. It lists `rest`, whose steps send HTTP requests, and that’s all we need. The first time, Axx prepared itself with that pack, which it won’t need to do again. We won’t need the other files in this tutorial: they are for CI and for coding agents.
## Give the web server something to serve
[Section titled “Give the web server something to serve”](#give-the-web-server-something-to-serve)
Our web server will serve the files in this directory. Create a small JSON file for it:
```sh
echo '{"message": "Hello, axx"}' > hello.json
```
## Tell Axx how to start the server
[Section titled “Tell Axx how to start the server”](#tell-axx-how-to-start-the-server)
Open `axx.yaml`. At the bottom, replace the whole `services:` section with this one:
axx.yaml
```yaml
services:
hello-axx:
command: python3 -m http.server 8000
ready:
http:
url: http://${sys:local.host}:8000/
```
Axx now knows how to start the server, and how to tell when it’s ready: when `http://localhost:8000/` answers.
## Write the scenario
[Section titled “Write the scenario”](#write-the-scenario)
Replace everything in `features/smoke/smoke.feature` with:
features/smoke/smoke.feature
```gherkin
Feature: Hello axx
Background:
Given the hello-axx service with the following properties:
| url | http://${sys:local.host}:8000 |
Scenario: The service says hello
Given a GET request to /hello.json
When the request is executed
Then the response status code is 200
And the response payload property message is 'Hello, axx'
```
Every line is a step Axx already knows. The `Background` registers the server as a service named `hello-axx`. The scenario sends it a request and checks the answer.
## Check the scenario
[Section titled “Check the scenario”](#check-the-scenario)
Before running anything, ask Axx to check every line:
```sh
axx validate
```
```console
1 file, 1 scenario, 5 steps: ok
```
Axx matched each line to one of its steps, without starting the server.
## Run it
[Section titled “Run it”](#run-it)
```sh
axx run
```
```console
axx: starting hello-axx (logs: .axx/logs/services.log)
Feature: Hello axx
Scenario: The service says hello # features/smoke/smoke.feature:7
✓ Given the hello-axx service with the following properties: (background)
| url | http://${sys:local.host}:8000 |
✓ Given a GET request to /hello.json
✓ When the request is executed
log: GET http://localhost:8000/hello.json -> 200 OK (4ms, 26 bytes)
attachment: response body (application/json, 26 B)
{"message": "Hello, axx"}
✓ Then the response status code is 200
✓ And the response payload property message is 'Hello, axx'
1 scenario (1 passed)
5 steps (5 passed)
Finished in 4ms
```
Notice what happened. Axx started the web server, waited until it answered, ran the scenario, and stopped the server again. Under the request step, the `log:` line shows the request Axx sent, and the attachment shows the response it got back.
## Break it on purpose
[Section titled “Break it on purpose”](#break-it-on-purpose)
Change the last line of `features/smoke/smoke.feature` so it expects a different message:
```gherkin
And the response payload property message is 'Hello, world'
```
Run it again:
```sh
axx run
```
```console
axx: starting hello-axx (logs: .axx/logs/services.log)
Feature: Hello axx
Scenario: The service says hello # features/smoke/smoke.feature:7
✓ Given the hello-axx service with the following properties: (background)
| url | http://${sys:local.host}:8000 |
✓ Given a GET request to /hello.json
✓ When the request is executed
log: GET http://localhost:8000/hello.json -> 200 OK (2ms, 26 bytes)
attachment: response body (application/json, 26 B)
{"message": "Hello, axx"}
✓ Then the response status code is 200
✗ And the response payload property message is 'Hello, world'
Response payload property message is not Hello, world
expected: "Hello, world"
actual: "Hello, axx"
Failed scenarios:
✗ The service says hello # features/smoke/smoke.feature:7
rerun: axx run features/smoke/smoke.feature:7
1 scenario (1 failed)
5 steps (1 failed, 4 passed)
Finished in 3ms
```
Axx marks the step that failed and shows what it expected next to what it got. It also prints a command that reruns only this scenario.
Change the line back to `'Hello, axx'` and run `axx run` once more. The scenario passes again.
## What you’ve done
[Section titled “What you’ve done”](#what-youve-done)
You installed Axx, told it how to start a service, wrote a scenario, ran it, and read a failure. That’s the loop you’ll use with every Axx suite.
Next, in [Your first suite](/next/tutorials/first-suite/), we’ll test a real service with a database and a dependency it calls.
# Test with an agent
> Hand an acceptance criterion to a coding agent, watch it write and run the scenario with Axx, and review what it wrote.
In this tutorial, we’ll give a coding agent an acceptance criterion in plain words and watch it write a scenario, check it and run it with Axx. Then we’ll review what it wrote.
We’ll use [Claude Code](https://claude.com/claude-code) and the `hello-axx` project from the [Quickstart](/next/tutorials/quickstart/). You’ll need both.
## Teach the agent Axx
[Section titled “Teach the agent Axx”](#teach-the-agent-axx)
`axx init` installed Axx’s skills in `.agents/skills` in the Quickstart. The `hello-axx` directory had no sign of Claude Code then, so connect it now. In the `hello-axx` directory, give Claude Code the Axx MCP server and link the skills where it reads them:
```sh
axx mcp install --agent claude
axx skills install
```
```console
create .mcp.json: the axx MCP server for Claude Code
installed 5 skills to .agents/skills (0 files updated)
linked for Claude Code in .claude/skills
```
The skills teach an agent how to find Axx’s steps, write a scenario, run it and read a failure. The MCP server gives it tools to do so, and the `AGENTS.md` that `axx init` wrote points agents at the same rules. In a repository that already uses Claude Code, `axx init` does all of this itself.
## Ask for a test
[Section titled “Ask for a test”](#ask-for-a-test)
Start Claude Code in the project:
```sh
claude
```
It asks whether to use the `axx` server from `.mcp.json`; allow it.
Give it the criterion, not step text:
```text
Add an acceptance test for this: asking for a file that doesn't exist returns 404. Use axx, and run it.
```
## Watch what it does
[Section titled “Watch what it does”](#watch-what-it-does)
The agent’s wording will differ from run to run, but you’ll see it work through the same loop. Claude Code asks before each command and each tool call, so you can follow along. Notice that it:
1. reads the project’s steps with the `steps_search` tool (or `axx steps`) instead of guessing their text;
2. checks a line it’s unsure of with `step_explain` (`axx explain`);
3. writes a feature file;
4. starts the server with `env` (`axx up`), runs the scenarios with `scenarios_run` (`axx run --compact`), which also reports what `axx validate` and `axx lint` find, and stops the server again.
## Review what it wrote
[Section titled “Review what it wrote”](#review-what-it-wrote)
Open the feature file the agent created. In our run it was `features/missing-file.feature`:
features/missing-file.feature
```gherkin
Feature: Missing files
Asking for a file the service does not have is answered with Not Found.
Background:
Given the hello-axx service with the following properties:
| url | http://${sys:local.host}:8000 |
Scenario: Asking for a file that does not exist returns 404
Given a GET request to /no-such-file-404.json
When the request is executed
Then the response status code is 404
```
Read it against the criterion you gave. Each line says what it checks, so you can tell at a glance whether the agent tested what you asked for. That review is the part that stays yours.
## Run it yourself
[Section titled “Run it yourself”](#run-it-yourself)
```sh
axx run
```
The end of the output shows both scenarios, the Quickstart’s and the agent’s:
```console
2 scenarios (2 passed)
9 steps (9 passed)
```
## What you’ve done
[Section titled “What you’ve done”](#what-youve-done)
You gave an agent a criterion in plain words. It found the steps, wrote the scenario, checked it and ran it, and you confirmed it by reading it.
[Set up agents](/next/guides/set-up-agents/) connects other agents and Axx’s MCP server, and [Axx for agents](/next/explanations/axx-for-agents/) explains why Axx works this way.
# Agent evaluations
> How coding agents do with axx and without it, on the same tasks, held to the same checks, with bugs planted in the service.
Coding agents write acceptance tests for a parcels service (REST and OpenAPI, PostgreSQL, MongoDB, Kafka, a downstream service mocked with WireMock), from acceptance criteria, as a developer would ask them to. Each task runs under five conditions: with axx and none of its aids (`none`), with its agent skills (`skills`), its MCP server (`mcp`), both (`both`), and without axx at all, tests of any kind run by a script (`plain`).
Every suite is held to the same **core checks**: it passes against the correct service, twice and against variants that differ only where the contract allows, and it fails against every bug planted in the service. A suite that passes them all would catch those bugs in a real project. Everything is public in [nimbusxr/axx-evals](https://github.com/nimbusxr/axx-evals): the tasks, the planted bugs, the verifier, and every run’s transcripts.
## openai/gpt-6-luna
[Section titled “openai/gpt-6-luna”](#openaigpt-6-luna)
2026-10-03, axx 0.1.12, opencode through OpenRouter ([the run](https://github.com/nimbusxr/axx-evals/actions/runs/37149658056)).
### With axx and without
[Section titled “With axx and without”](#with-axx-and-without)
The 6 tasks that have a version without axx (`kafka-registered-event`, `mock-address-check`, `mongo-tracking-view`, `openapi-reject-invalid`, `rest-crud-happy-path`, `sql-manifest-import`), each condition’s suites held to the same core checks: pass against the correct service twice and against its correct variants, and fail against every planted bug.
**With axx, 72 of 72 suites passed every core check (100%)**, across none, skills, mcp, both; **without axx, 14 of 18 (78%)**. Without axx: 3 failed against the correct service, 1 failed against a correct variant. The agents wrote a median of 76 lines per suite with axx and 170 without.
| On these tasks | none | skills | mcp | both | plain (no axx) |
| ---------------------------------- | --------- | --------- | --------- | --------- | -------------- |
| **Passed every core check** | **18/18** | **18/18** | **18/18** | **18/18** | **14/18** |
| Failed against the correct service | 0 | 0 | 0 | 0 | 3 |
| Failed against a correct variant | 0 | 0 | 0 | 0 | 1 |
| Lines written per suite (median) | 60 | 86 | 80 | 83 | 170 |
| Agent minutes per trial | 1.0 | 1.2 | 1.3 | 1.3 | 1.3 |
| Requests learning axx per trial | 4.4 | 3.2 | 4.2 | 3.6 | 0.0 |
| Cost per trial (¢) | 1.08 | 1.45 | 1.01 | 1.23 | 0.86 |
| **Cost per passing suite (¢)** | **1.08** | **1.45** | **1.01** | **1.23** | **1.10** |
Lines written are the lines of the files the agent added or changed: features and seeds with axx, test code and scripts without. Requests learning axx are those whose tool calls mostly read its steps, docs, skills or help. The cost per passing suite is what every trial cost, divided by the suites that passed every core check. The run’s report lists every failed suite and why.
### Scores by task
[Section titled “Scores by task”](#scores-by-task)
The share of each task’s trials that passed every check; the core score holds every condition to the same checks, and the score adds what only an axx suite has (`axx validate`, the features’ readability, `axx lint` where the task asks).
| Task | none | skills | mcp | both | plain |
| ------------------------ | --------- | --------- | --------- | --------- | -------- |
| `fix-broken-feature` | 3/3 | 3/3 | 3/3 | 3/3 | - |
| `init-first-feature` | 3/3 | 3/3 | 3/3 | 3/3 | - |
| `kafka-registered-event` | 3/3 | 3/3 | 3/3 | 3/3 | 0/3 |
| `mock-address-check` | 3/3 | 3/3 | 3/3 | 3/3 | 2/3 |
| `mongo-tracking-view` | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 |
| `openapi-reject-invalid` | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 |
| `parallel-unique-data` | 3/3 | 3/3 | 3/3 | 3/3 | - |
| `rest-crud-happy-path` | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 |
| `sql-manifest-import` | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 |
| **Core score** | **100.0** | **100.0** | **100.0** | **100.0** | **77.8** |
| **Score** | **100.0** | **100.0** | **100.0** | **100.0** | **77.8** |
# Axx for agents
> The design choices that make Axx usable by coding agents - discoverable steps, validation without side effects, compact structured output, stable contracts, and docs served from the binary.
Coding agents are now a primary user of test tools: they write most new tests, run them, and act on the result. Axx is designed for that user alongside people. The principle: an agent should never have to guess.
## Never guess a step
[Section titled “Never guess a step”](#never-guess-a-step)
Agents invent plausible step text, and Gherkin punishes near-misses. Axx makes the real steps cheap to find and cheap to check:
* `axx steps` and the `steps_search` MCP tool without a query list every step of the project in one line each, which an agent reads once; `axx steps show ` and `step_explain` give one step’s documentation and examples. Searches return the steps that fit, briefly, and the MCP tool sends a step it returned before as its id only, so what an agent learns does not fill its context twice.
* The skills send agents to `axx steps`, which lists the project’s steps, custom ones included, rather than shipping pages of them: agents read pages a chunk at a time, about ten times the tokens of `axx steps show `.
* `axx validate` and `axx explain` check every line without starting anything, and suggest the closest real steps for a near-miss.
* The `steps_try` MCP tool runs steps in a live scenario that stays open between calls, so an agent sees what a step does before it writes it down; the packs’ tools look at that scenario, such as the web-core pack’s `web_page`, which shows the page as the steps name its elements.
* Step text is public API: it never changes, so what an agent learned stays true.
## Never guess what happened
[Section titled “Never guess what happened”](#never-guess-what-happened)
* **Exit codes** classify the outcome: a failed scenario (`1`), a broken setup (`2`), an invented step (`3`), a service that did not start (`4`).
* **Error codes** (`AXX-Exxxx`) are stable and come with a hint and a link.
* **Failures are data.** Assertion failures carry expected and actual values, the matched step definition, pack context such as the last HTTP exchange, and the exact command that reruns the scenario.
* **`--json` everywhere**, in one envelope, with a frozen schema.
## Spend tokens on the problem
[Section titled “Spend tokens on the problem”](#spend-tokens-on-the-problem)
When Axx detects an agent and its output is not a terminal, it switches to compact output: failures and one summary line. A green run of a thousand scenarios costs one line of context. Details are one call away (`axx run --json`, or the `failure_context` MCP tool).
## Stay in sync with the installed version
[Section titled “Stay in sync with the installed version”](#stay-in-sync-with-the-installed-version)
Documentation drifts; binaries do not. The MCP server, `axx steps`, the skills and the reference pages on this site are all generated from the binary (ADR 0005), so an agent that asks the tool gets answers for the version in the repository, including that project’s custom packs.
## A fast, safe loop
[Section titled “A fast, safe loop”](#a-fast-safe-loop)
`axx up` keeps the system under test running between runs, so an agent’s edit-run loop costs seconds instead of minutes. `axx run features/x.feature:LINE` reruns exactly one scenario. Validation, explanation and step search have no side effects and need no running services.
## Guardrails in the instructions
[Section titled “Guardrails in the instructions”](#guardrails-in-the-instructions)
The skills and the AGENTS.md section encode the rules that keep agent-written tests honest:
* one scenario per acceptance criterion, named after the behavior;
* unique data in every scenario, and fixture factories where data repeats (optional, and `axx lint` hints at them);
* assert on observable outcomes, never loosen an assertion to go green;
* no fixed sleeps, use readiness checks and polling steps.
## Docs for machines
[Section titled “Docs for machines”](#docs-for-machines)
Every page on this site has a Markdown twin (append `.md`), advertised with ``. [`/llms.txt`](/next/llms.txt) indexes them, and [`/.well-known/agent-skills/index.json`](/next/.well-known/agent-skills/index.json) lists the skills. Each page also has *Copy as Markdown* and *Open in Claude* buttons.
`axx init` sets up the skills and the MCP server for the agents a repository already uses, in the repository’s own files, and `axx doctor` says what an agent lacks. Read [Set up agents](/next/guides/set-up-agents/), and see it work in [Test with an agent](/next/tutorials/with-an-agent/).
How much this helps is measured: [Agent evaluations](/next/explanations/agent-evals/) runs coding agents on the same tasks with axx and without it, against a service with planted bugs.
# Black-box acceptance testing
> Why Axx tests services from the outside, through the same interfaces their clients use, and what that buys you over in-process tests.
An **acceptance test** checks that the system does what was agreed: the acceptance criteria of a story, written so the people who agreed on them can read the test. A **black-box** test checks it the way a client would, through the system’s public interfaces, with no knowledge of its insides.
Axx does both. A scenario sends real requests to a running service, puts real rows in its real database, publishes real events to its broker, and checks what comes back and what the service did to the world.
## Why from the outside
[Section titled “Why from the outside”](#why-from-the-outside)
* **The test survives refactoring.** Rename a class, swap a framework, rewrite the service in another language: the scenarios still describe the same behavior and still run.
* **It tests what ships.** The artifact under test is the one you deploy (a container, a binary), started the way you start it, configured the way you configure it. Serialization, framework wiring, database constraints and migrations are all in the loop.
* **It needs no access to the code.** The service does not depend on Axx or on any test library. A team can write acceptance tests for a service in any language, and an agent can write them without reading the implementation.
* **It reads like the requirement.** A scenario is the acceptance criterion in structured English. When it fails, the name says which promise broke.
## What it costs, and how Axx pays it
[Section titled “What it costs, and how Axx pays it”](#what-it-costs-and-how-axx-pays-it)
Black-box suites have a reputation for being slow and flaky. The causes are specific, and so are the fixes:
| Cause | What Axx does |
| ---------------------------------------- | -------------------------------------------------------------------------- |
| Starting the system for every test class | starts services once per run, or once per session with `axx up` |
| Tests run one at a time | runs scenarios in parallel by default |
| Tests share and corrupt data | makes data isolation checkable with `axx lint` and fixture identities |
| Waiting with fixed sleeps | readiness checks for services, polling steps for asynchronous results |
| Brittle hand-written payloads | payloads from OpenAPI examples and fixture factories |
| Opaque failures | expected and actual values, the last request and response, a rerun command |
## Where it fits
[Section titled “Where it fits”](#where-it-fits)
Black-box acceptance tests sit on top of unit and integration tests; they do not replace them. Use them for the behavior a client or another team relies on: the API contract, the events you publish, the data you persist, the calls you make to dependencies. Keep edge cases of pure logic in unit tests, where they are cheaper.
## Contracts in both directions
[Section titled “Contracts in both directions”](#contracts-in-both-directions)
A service has two contracts: the API it provides and the APIs it consumes. Axx checks both from the outside:
* **Provided**: every request and response is validated against your OpenAPI document ([OpenAPI as contract](/next/explanations/openapi-contract/)).
* **Consumed**: dependencies are replaced by WireMock mocks, which can themselves be validated against the provider’s OpenAPI document ([Mock dependencies](/next/guides/mock-dependencies/)).
# How Axx works
> What happens between typing axx run and reading the result - configuration, step matching, service lifecycle, parallel execution, packs, the world and reporting.
Axx is one Go binary with its own Cucumber executor. It reads Gherkin, matches every step to a definition, starts the system under test, runs the scenarios against it from the outside, and reports.
```text
axx.yaml ─┐
features ─┼─▶ load & validate ─▶ match steps ─▶ start services ─▶ run scenarios ─▶ report ───────────────▶ stop services
packs ─┘ (registry) (lifecycle) (workers) (pretty, junit, html…) (cleanup)
```
## 1. Load
[Section titled “1. Load”](#1-load)
Axx finds `axx.yaml` by walking up from the working directory, applies the selected profile and `axx.local.yaml`, expands `${env:...}` and `${sys:...}`, and validates the result against the [schema](/next/references/config/). It then parses the feature files with the official Cucumber Gherkin parser and applies the path, line, tag and name filters.
## 2. Match
[Section titled “2. Match”](#2-match)
Every step line is matched against the **registry**: the steps of the packs the project loads. Steps are [Cucumber Expressions](https://github.com/cucumber/cucumber-expressions) with custom parameter types such as `{ordinal}` and `{service}`. Optional segments like `[[ on {service}]]` register every variant of a step.
A line that matches nothing is *undefined*; a line that matches two definitions is *ambiguous*. `axx validate`, `axx explain` and `axx run --dry-run` are this phase alone: they find both without starting a service or running a step. In a run, a scenario stops at an undefined or ambiguous step, and the report suggests the closest real steps or lists the candidates.
## 3. Start the services
[Section titled “3. Start the services”](#3-start-the-services)
The lifecycle manager starts `services` one after another in the order `axx.yaml` declares them, or, when services declare `dependsOn`, in dependency order with independent services in parallel. Each runs in its own process group. It polls every `ready` check until they pass or time out. With `active.enabled`, only the services the selected scenarios’ tags call for start, with the services they depend on. With `axx up`, a background supervisor keeps them running and later runs skip this step.
## 4. Run
[Section titled “4. Run”](#4-run)
Scenarios run in parallel on `run.workers` workers; steps within a scenario run in order. Scenarios tagged with a `run.exclusive` tag run one at a time after the parallel phase.
Each scenario gets a fresh **world**: its registered services and the state each pack keeps (requests built and responses received, selections, events, browser pages). Nothing in the world is shared between scenarios, which is why parallel runs are safe as long as the *external* data is unique. See [Scenario isolation](/next/explanations/scenario-isolation/).
## 5. Packs
[Section titled “5. Packs”](#5-packs)
A **pack** is a bundle of steps, parameter types and hooks, its settings in `axx.yaml` and its tools for agents (`axx mcp`), plus the per-scenario context its steps share. The packs are `rest`, `mock`, `grpc`, `jsonrpc`, `graphql`, `sql`, `mongo`, `redis`, `kafka`, `amqp`, `mqtt`, `nats`, `websocket`, `sse`, `asyncapi`, `logs`, `files`, `mail`, `cli`, the web packs (`web-core`, `web-screenshots`…), the cloud service packs (`aws-s3`, `gcp-pubsub`, `azure-blob`…), and `core` for shared parameter types.
A project lists the packs it uses in `axx-packs.yaml`. The `axx` binary is the core alone: on first use, it builds a copy of itself with the project’s packs, caches it and runs it in its place ([Choose packs](/next/guides/use-packs/)).
## 6. Report and stop
[Section titled “6. Report and stop”](#6-report-and-stop)
Reporters follow the run as it goes, as [Cucumber Messages](https://github.com/cucumber/messages) events and scenario results, and finish their reports after the last scenario: pretty, progress or compact on the console, TeamCity service messages for the editors’ test runners, and JUnit, HTML, Cucumber JSON, NDJSON messages and the JSON agent report to files. Then services are stopped (signal, grace period, kill) and every `cleanup` runs, including after a crash or an interrupt. The exit code summarizes the worst outcome ([exit codes](/next/references/error-codes/#exit-codes)).
## What Axx is not
[Section titled “What Axx is not”](#what-axx-is-not)
* **Not a unit test framework.** Axx tests a running system through its public interfaces. It never loads your code.
* **Not a load-testing tool.** Parallelism is for speed, not for generating traffic.
# OpenAPI as the contract
> Why Axx treats your OpenAPI document as the single source of truth for an API, validates every exchange against it, and builds payloads from its examples.
An API has one contract, and it should live in one place. In Axx that place is the OpenAPI document. Scenarios do not restate the contract; they rely on it.
## The document is the source of truth
[Section titled “The document is the source of truth”](#the-document-is-the-source-of-truth)
Some contract-testing approaches make the tests the source of truth and generate stubs or documents from them. Then the published OpenAPI document and the tested behavior can disagree, and nobody notices until a client breaks. Axx goes the other way: the document the service publishes is the contract, and the acceptance suite proves the service honors it.
When a REST service is registered with an `openapi` property, every request a scenario sends and every response it receives is validated against that document. A scenario that checks only the status code still catches a missing required field, a wrong type or an undocumented status, because the validator checks everything the scenario did not.
## Examples are test data
[Section titled “Examples are test data”](#examples-are-test-data)
Documents already carry examples for their operations. Axx uses them as the starting payload (`a request payload using an application/json content example`), so a scenario lists only the values it is about. Two good effects follow:
* The examples in your published documentation are exercised on every run, so they stay correct.
* When a schema gains a field, you update the example once instead of every scenario.
## Strict by default, relaxed on purpose
[Section titled “Strict by default, relaxed on purpose”](#strict-by-default-relaxed-on-purpose)
Every validation rule starts at `ERROR`. A negative test, which sends an invalid request on purpose to check that the service rejects it, relaxes exactly the rule it breaks, in that scenario only:
```gherkin
Given the OpenAPI validation levels are:
| validation.request.body.schema.maximum | IGNORE |
```
That line comes from a scenario that sends a 31 kg parcel to check that the service refuses parcels over 30 kg: the request breaks the document’s `maximum`, and nothing else is relaxed.
The relaxation is visible in the feature file, next to the behavior that needs it. Suite-wide relaxations in `axx.yaml` exist for documents you do not control; using them for your own contract defeats the point.
## Both sides of a dependency
[Section titled “Both sides of a dependency”](#both-sides-of-a-dependency)
A contract has two sides, and Axx checks both. Your service’s own document is checked by the REST steps: the provider side, where your service keeps its promise to its clients. The documents of the services your service calls are checked by the WireMock image that mocks them: the consumer side, where your service calls its dependencies correctly and the mocks answer as the real services would, so a stub cannot promise something the real API never does.
The two sides have separate settings. Relaxing a rule of your own contract for a negative test says nothing about a dependency’s contract, and a stub that answers off-contract on purpose says nothing about yours. The provider validates its implementation against its document; the consumer validates its calls and mocks against the same document. Neither side needs the other’s tests.
See [Validate against OpenAPI](/next/guides/validate-openapi/) for the steps and settings, and [Mock dependencies](/next/guides/mock-dependencies/) for the consumer side.
# Scenario isolation and test data
> Why Axx shares infrastructure between scenarios but never data, how that makes parallel runs safe, and the tools that enforce it.
A fast black-box suite shares infrastructure: one database, one broker, one WireMock, one running service for hundreds of scenarios running in parallel. It stays correct only if scenarios never share **data**. Axx is built around that rule.
## What is isolated for you
[Section titled “What is isolated for you”](#what-is-isolated-for-you)
Each scenario has its own **world**: the services it registered, the requests it built and the responses it received, its selections and its events, and its browser pages, with their own cookies and storage. Nothing in the world leaks into another scenario, and a scenario always runs on a single worker. REST requests share connections, but no cookies: a request sends only the cookies its scenario gives it ([Send REST requests](/next/guides/send-rest-requests/#cookies)).
## What you isolate
[Section titled “What you isolate”](#what-you-isolate)
Everything outside Axx persists: rows in the database, documents in MongoDB, events on topics, requests in the WireMock journal, entries in logs, files in folders and buckets. Those are visible to every scenario, including ones running at the same moment and ones in the next run. So:
1. **Every scenario owns its data.** References, ids, keys and emails belong to one scenario: `PX-REG-1004`, not `test`. A scenario that registers a parcel for the sender `shop-example` and then counts that sender’s parcels will pass alone and fail beside any other scenario that does the same.
2. **Assert on your own data only.** Select rows by your ids; name mock request patterns by URLs that contain your ids; consume events by your keys.
3. **Do not rely on cleanup.** Data from earlier runs, failed and interrupted ones included, is still there. A scenario must pass beside everyone else’s data. A scenario that inserts a fixed id (a seed, a registration with a fixed reference) needs that id to be free, so it runs again only on a fresh environment: `axx run` starts from empty databases when the services’ `cleanup` removes their data (as `docker compose down -v` does), and against `axx up`, restart with `axx down` and `axx up`.
## Enforcing it
[Section titled “Enforcing it”](#enforcing-it)
Conventions drift, so Axx makes them checkable:
* **`axx lint`** extracts ids from seeds, payloads and features and fails when a value appears where it must be unique ([Isolate test data](/next/guides/isolate-test-data/)).
* **Fixture identities**: an `identity:` in a factory spec derives unique values for every fixture and generates the matching lint rule ([Fixture factories](/next/guides/fixture-factories/)).
* **Randomized order**: `axx run --order random` exposes scenarios that only pass after another one ran.
## When sharing is unavoidable
[Section titled “When sharing is unavoidable”](#when-sharing-is-unavoidable)
Some scenarios change the environment for everyone: a database trigger that makes inserts fail, a global feature flag, a dependency that is stopped on purpose. Tag them with a tag listed in `run.exclusive` (for example `@isolated`). Axx runs them one at a time after the parallel phase, so they never overlap with another scenario ([Run in parallel](/next/guides/parallel-runs/)).
## Why not reset the database between scenarios
[Section titled “Why not reset the database between scenarios”](#why-not-reset-the-database-between-scenarios)
Truncating tables or restoring snapshots makes scenarios serial (a reset in one breaks another that is running), slow, and blind to problems that only appear with realistic data volumes. Unique data costs a naming convention and gives you parallel runs against a long-lived environment, including `axx up` sessions that last all day.
# Step design
> Why Axx steps are shaped the way they are - variants, parameter types, tables versus arguments - and why step text never changes.
The steps of Axx’s packs follow a few rules, so once you know one pack you can guess the others, and an agent can too.
## One step, several variants
[Section titled “One step, several variants”](#one-step-several-variants)
The steps that address a service, a database or a broker come in up to four forms: the plain step, a form that names the service, a form with an ordinal for the 2nd or 3rd request, selection or event, and both together. The plain form covers the common case, a scenario with one service and one request, so most scenarios read like the acceptance criterion. The other forms appear only when a scenario needs them, and they read the same way in every pack ([How steps read](/next/references/steps/) has the grammar):
```gherkin
Scenario: A reference can only be registered once
Given a 1st ordered POST request to /api/parcels
And a request payload using an application/json content example for 1st ordered request
And the request payload property reference is 'PX-REG-1004' for 1st ordered request
And a 2nd ordered POST request to /api/parcels
And a request payload using an application/json content example named 'Express parcel' for 2nd ordered request
And the request payload property reference is 'PX-REG-1004' for 2nd ordered request
When the 1st ordered request is executed
And the 2nd ordered request is executed
Then the 1st ordered response status code is 201
And the 2nd ordered response status code is 409
```
## Parameter types
[Section titled “Parameter types”](#parameter-types)
Besides Cucumber’s built-in types (`{int}`, `{word}`, `{string}`, …), Axx adds types that let steps read naturally: `a 2nd selection`, `within 5s`, `on parcels`, `on parcels-db`. `{filepath}` marks a value that names a file, such as a seed or a schema, so editors can link it to the file. They are listed in [How steps read](/next/references/steps/#parameter-types).
## Tables for many values, arguments for one
[Section titled “Tables for many values, arguments for one”](#tables-for-many-values-arguments-for-one)
Steps that set or check one value take it inline (`the request header Accept is 'application/json'`); their plural twins take a two-column table (`the request headers are:`). Tables use JSONPath keys for payloads, so nested and array values need no extra steps.
A table’s rows are its data: every row is a property and its value, or whatever its step’s columns say. It may start with a row naming those columns, as the reference shows them (`| JSONPath | value |`), or go straight to its rows; axx sets such a row aside, for every table of two columns or more.
## Step text is public API
[Section titled “Step text is public API”](#step-text-is-public-api)
Once a step is released, its text never changes. New behavior gets new steps; old steps can be deprecated with a pointer to the replacement, never renamed. That is what keeps feature files working across releases, and what lets an agent trust `axx steps search` today and next year. Every build checks that each step in a frozen catalog of step text is still defined, word for word.
## Designing your own steps
[Section titled “Designing your own steps”](#designing-your-own-steps)
The same rules make [custom steps](/next/guides/write-custom-steps/) easy to use:
* Give every step a stable `ID`, its documentation (`Doc`) and a complete example line (`Examples`).
* Add an optional `on` part that names the service, with its pack’s parameter type (`[[ on {dbService}]]`), when a step talks to a service that can appear twice.
* Report failed expectations with expected and actual values, not just a message.
# How the web-core pack works
> How Axx drives real browsers - Playwright's browsers on your machine, a browser context for every scenario, steps that wait and find things the way people do - and why it works that way.
The `web-core` pack runs acceptance scenarios in real browsers. It stands on [Playwright](https://playwright.dev), the browser automation Microsoft maintains for Chromium, Firefox and WebKit, and it keeps to what Axx is for: scenarios people can read, black-box tests of what your service does ([Black-box testing](/next/explanations/black-box-testing/)).
## The browsers run on your machine
[Section titled “The browsers run on your machine”](#the-browsers-run-on-your-machine)
The browsers run where Axx runs, as they do for any Playwright project: Playwright’s builds of Chromium, Firefox and WebKit, which the pack downloads the first time it uses each one, or the Google Chrome and Microsoft Edge installed on the machine. Axx drives them through the Playwright driver, Node.js and Playwright’s own package, which it downloads once and checks against pinned hashes. So:
* The browsers reach your web apps at the addresses you would: `localhost`, whether their service runs in Compose or on your machine, under a debugger or not.
* Watching a run is watching browser windows on your screen, with their DevTools, as when you use the app yourself.
* Scenarios run the same everywhere: the pack pins one Playwright version, so its browsers are the same builds on every laptop and in CI, and it gives the browser a language and a time zone (`en-US` and `UTC`) unless the web app says otherwise, rather than the machine’s.
## Playwright’s tools, in steps
[Section titled “Playwright’s tools, in steps”](#playwrights-tools-in-steps)
Playwright’s own tools debug the scenarios: its Inspector, where a run pauses, its recorder and its trace viewer. They speak Playwright’s languages, and the pack teaches them the steps’ instead: the Playwright driver it downloads is patched, for the Playwright version it pins, so that
* the Inspector shows the feature file, at the step a paused scenario waits before, and every browser action belongs to its step;
* picking an element names it as steps do, `the "Weight (grams)" field`, and the Inspector finds what such a name finds;
* the recorder writes the steps people take, with the steps of the pack;
* a trace groups the browser’s actions by step, keeps the feature file, and names elements as steps do in the trace viewer your editor opens.
What people see names what steps find: an element that the recorder can only find by its position or its markup has no name for people either, and the recording says so.
## Isolation
[Section titled “Isolation”](#isolation)
Playwright isolates tests in **browser contexts**: a context is a fresh browser profile, with its own cookies, storage, cache, permissions and pages, and nothing of one is visible to another. The pack keeps one browser per engine for the whole run, and gives every scenario a context of its own, for each web app it registers. Scenarios run side by side without seeing each other’s sessions, and a context is gone when its scenario ends ([Scenario isolation](/next/explanations/scenario-isolation/)).
That is also why the pack has no way to sign in once and share the session with every scenario: shared, one scenario signing out would sign out the others, and scenarios would depend on each other again. A scenario signs in, or starts with its own copy of a session ([Sign in to web apps](/next/guides/web-sign-in/)).
## The current page
[Section titled “The current page”](#the-current-page)
A scenario acts on one page at a time, the current page, as a person looks at one tab. Opening a page makes it current; a link that opens a new tab makes the new tab current, as browsers show it; closing it goes back to the tab it came from. With several web apps, the one whose page was opened last is current.
A dialog the page opens blocks the page, for Axx as for people: until a step answers it, other steps fail and say so, rather than hang or go on behind it.
## Waiting instead of sleeping
[Section titled “Waiting instead of sleeping”](#waiting-instead-of-sleeping)
Pages change after the fact: a price arrives, a message goes, a button is enabled once the form is valid. Every action waits for its element to be visible, enabled and still; every check retries until the page shows what it expects, 10 seconds or `within {duration}`. A step that has to wait longer than that says what the page shows instead, and a scenario never needs a pause.
## Names first, selectors when needed
[Section titled “Names first, selectors when needed”](#names-first-selectors-when-needed)
Steps find things the way people do: a field by its label, a button by its name, text anywhere. This keeps scenarios readable to the people who own the acceptance criteria, keeps them working when the markup changes, and makes accessibility part of the test: a field a step cannot find by its label is one a screen reader cannot name either. Steps look inside frames the same way, since people see no frames.
Selectors (`css=`, `xpath=`, `testid=`) go where names are not enough: rows of a list, a canvas, a widget of a library with no accessible names. They take the name’s place in the same steps, so there is one set of steps to know.
## Secrets
[Section titled “Secrets”](#secrets)
Values a step takes from the environment, `${env:…}`, are secrets: passwords, tokens, session cookies. They are masked in failure messages, in the failure report, and in saved traces, where they would otherwise sit in the recorded actions, the snapshots of the page, and the form data and headers of its requests. Screenshots and videos show the page as it is.
## Packs that build on it
[Section titled “Packs that build on it”](#packs-that-build-on-it)
The `web-core` pack drives the browsers and holds the steps every web app needs. What only some projects need are packs of their own, which build on it as `aws-s3` builds on `aws-core`: a project adds the ones it uses ([Choose packs](/next/guides/use-packs/)).
| Pack | |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `web-screenshots` | pages that look as designed, each platform with its own screenshots ([Compare screenshots](/next/guides/web-screenshots/)) |
| `web-a11y` | WCAG audits with axe-core, and what a screen reader reads ([Check accessibility](/next/guides/web-accessibility/)) |
| `web-network` | what the pages fetch: requests that fail, answer or take their time, recordings, websockets ([Control what pages fetch](/next/guides/web-network/)) |
| `web-lighthouse` | Lighthouse scores and the Core Web Vitals ([Audit pages with Lighthouse](/next/guides/web-lighthouse/)) |
| `web-coverage` | how much of the web apps’ JavaScript the scenarios run ([Measure JavaScript coverage](/next/guides/web-coverage/)) |
They use the pages the `web-core` pack opens, found the way its steps find things: a screenshot of `the "Price" element` is of the element the other steps name so. The tools some of them bring (axe-core, Lighthouse) are downloaded the first time, as the browsers are.
## What Playwright cannot do
[Section titled “What Playwright cannot do”](#what-playwright-cannot-do)
A few limits are Playwright’s (or the browsers’) and hold in the pack too:
* Firefox has no mobile emulation: a `device` that is a phone needs `chromium` or `webkit`.
* WebKit shows some downloads in the page, text files among them, unless their link has the `download` attribute.
* Pages learn the browser’s location on secure addresses only (`https://`, or `localhost`).
* Google Chrome and Microsoft Edge exist for x86-64 Linux only; on ARM Linux machines, use Chromium, Firefox and WebKit.
# Check emails
> Check the emails your services send, by their recipients, subject, text, HTML, attachments and headers, read from Mailpit or any mailbox over POP3 or IMAP; and the mail they send through a provider's API.
Services email people: a label to a shop, a delivery notice, a password reset. Your service sends its mail as it does in production, by SMTP, to the mail server of your test environment, and the `mail` pack reads the mailbox that catches it.
```gherkin
Scenario: A shop gets its parcel's label by email
Given a seeds/mail-registered.yaml db seed
And a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload property reference is 'PX-MAIL-9701'
And the request payload property sender is 'juniper-and-jay'
When the request is executed
Then the response status code is 201
And within 10s the shops mailbox has an email where:
| to | orders@juniper-and-jay.example |
| subject | Parcel PX-MAIL-9701 registered |
| attachment | PX-MAIL-9701-label.zpl |
```
Add the pack to the project with `axx pack add mail` ([Choose packs](/next/guides/use-packs/)).
## Which mailbox
[Section titled “Which mailbox”](#which-mailbox)
How your service sends its mail decides where a test finds it:
| Your service sends | In the tests | The check |
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| by **SMTP**, to a relay or a provider’s SMTP endpoint | a mail server that catches it: [smtp4dev](https://github.com/rnwood/smtp4dev), which can also stub refusals, or [Mailpit](https://mailpit.axllent.org), GreenMail, Inbucket | a mailbox with an `imap://` or `pop3://` `url`, or Mailpit’s, read through its API |
| by SMTP, and it must really arrive | a real inbox, in a staging environment | a mailbox with an `imaps://` `url` |
| through a **provider’s HTTP API** (SendGrid, SES, Mailgun, Postmark, Resend) | WireMock, against the provider’s contract | the [mock pack](/next/guides/mock-dependencies/): the request’s payload properties |
Point your service’s SMTP settings at the mail server of the test environment, as the parcels example does in its Compose file:
compose.yaml
```yaml
services:
mail:
image: rnwood/smtp4dev:3.15.0
ports: ['1025:25', '1143:143', '8025:80'] # SMTP, IMAP, its web interface
app:
environment:
PARCELS_SMTP_ADDR: mail:25
```
## Register the mailbox
[Section titled “Register the mailbox”](#register-the-mailbox)
```gherkin
Background:
Given the shops mailbox with the following properties:
| url | imap://${sys:local.host}:1143 |
| username | axx |
| password | axx |
```
* **`url`:** Mailpit’s address (`http://` or `https://`), or `pop3://`, `pop3s://`, `imap://` or `imaps://` and the mail server’s host.
* **`username` and `password`:** sign in over POP3 and IMAP, or to a Mailpit behind a password. The password is masked.
* **`folder`:** the IMAP folder, `INBOX` unless it says otherwise.
smtp4dev takes any username and password over IMAP, and its `INBOX` has every email it received. Mailpit answers POP3 once it is given a password file (`--pop3-auth-file`, or `MP_POP3_AUTH`).
## Check an email
[Section titled “Check an email”](#check-an-email)
```gherkin
Then within 10s the shops mailbox has an email where:
| to | orders@larkspur-lane.example |
| subject | Parcel PX-MAIL-9702 delivered |
| html | PX-MAIL-9702 was delivered in Leipzig |
```
| Row | What it checks |
| --------------- | ---------------------------------------------- |
| `to`, `cc` | an address the email went to, or was copied to |
| `from` | the address it came from |
| `subject` | its subject, all of it |
| `text` | text its plain-text body contains |
| `html` | text its HTML body contains, tags and all |
| `attachment` | the file name of an attachment |
| `header ` | a header’s value |
Addresses compare without regard to case, and `text` and `html` take runs of spaces and line breaks as one space. Subjects and names in other charsets are decoded first. The check waits for an email that meets every row: 10 seconds, or the time `within` gives. When none does, it lists the emails that arrived.
## Refuse mail
[Section titled “Refuse mail”](#refuse-mail)
What does your service do when a mail server refuses its email? Stub the refusal in the mail server, as you stub an HTTP dependency’s failures in WireMock’s mappings: by the data a scenario uses. smtp4dev runs a JavaScript expression for each recipient, and `error(code, message)` refuses it with that SMTP code:
compose.yaml
```yaml
services:
mail:
image: rnwood/smtp4dev:3.15.0
environment:
# quince-and-quill's mail server is busy: it asks to be tried again later.
ServerOptions__RecipientValidationExpression: >-
recipient.toLowerCase().endsWith("@quince-and-quill.example")
? error(451, "Mailbox busy, try again later") : true
```
A scenario whose shop is quince-and-quill then sees how your service copes:
```gherkin
Scenario: A registration email the shop's mail server refuses does not stop the registration
Given a seeds/mail-refused.yaml db seed
And a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload property sender is 'quince-and-quill'
When the request is executed
Then the response status code is 201
```
* A 4xx code, like 451, is a failure the sender should retry; a 5xx, like 550, one it should not.
* The stub refuses only the recipients it names, so scenarios running beside it are not affected: give each refusal a recipient of its own.
* `CommandValidationExpression` refuses at other stages, such as the sender’s `mail from:` (smtp4dev passes the command’s verb in lowercase), and `MessageValidationExpression` refuses whole messages.
## Keep scenarios apart
[Section titled “Keep scenarios apart”](#keep-scenarios-apart)
A check only looks at the emails that arrived since its scenario started, and axx never deletes or marks what it reads. Scenarios running in parallel share the mailbox, so each checks its own mail by a recipient or a subject unique to it, such as a shop or a parcel reference of its own.
For a run, axx reads each mailbox the checks name from when the services are up:
* **Mailpit and IMAP** say when each email arrived.
* **POP3** doesn’t, so axx takes what the mailbox held when it started reading as old.
See the [mail pack’s reference](/next/references/packs/mail/) for every step.
# Check files
> Check the files your services write to a folder or to cloud storage, whether they are CSV, JSON, PDF, Word or Excel files, by their exact content, their JSON properties, their text or the rows of their table.
Services write files for other systems and for people: an import report that a shop’s system collects, a workbook for its staff, a handover note the driver signs. The `files` pack checks the files a service writes to a folder, such as an export directory or a volume it shares with Axx. The storage packs have the same checks for the objects in a bucket or a container.
```gherkin
Scenario: The import report says what became of every line
Given a seeds/manifest-heron.yaml db seed
Then within 10s the exports folder has a file named manifests/M-HERON-0501/report.csv
And the manifests/M-HERON-0501/report.csv file in the exports folder has a row where:
| line | ML-HER-0501-3 |
| status | REJECTED |
| reason | unknown service level |
```
Add the pack to the project with `axx pack add files` ([Choose packs](/next/guides/use-packs/)).
## Register the folder
[Section titled “Register the folder”](#register-the-folder)
Register the folder under a name, usually in the `Background`. Its `path` is relative to the directory of `axx.yaml`, or absolute, and it can use `${env:..}` and `${sys:..}`:
```gherkin
Background:
Given the exports folder with the following properties:
| path | ../infra/exports |
```
The folder may appear only when the service first writes to it. A service in a container writes to a folder of the host through a bind mount:
infra/compose.yaml
```yaml
services:
exports:
image: busybox:1.37
volumes: ['./exports:/exports']
command: ['sh', '-c', 'rm -rf /exports/* && chmod 777 /exports']
app:
build: ../app
environment:
PARCELS_EXPORT_DIR: /var/lib/parcels/exports
volumes: ['./exports:/var/lib/parcels/exports']
depends_on:
exports:
condition: service_completed_successfully
```
A folder keeps what earlier runs wrote to it, so a check could pass on a file from the run before. The `exports` service empties the folder before the service starts, and lets a service that runs as a non-root user write to it. Name the files you check after data unique to the scenario, such as a manifest ID: scenarios run in parallel.
## Put files in a folder
[Section titled “Put files in a folder”](#put-files-in-a-folder)
A scenario can put files in a folder, as a service or an app would find them there: a copy of a file of the project, or a file with the content of a doc string, where `${env:..}` and `${sys:..}` are expanded. The folders a file is in are made, and a file already there is replaced.
```gherkin
Given the incoming/M-HERON-0501.csv file in the imports folder is a copy of the manifests/M-HERON-0501.csv file
And the incoming/M-HERON-0501.json file in the imports folder has the content:
"""json
{"manifest": "M-HERON-0501", "shop": "heron-garden", "lines": 3}
"""
```
`is emptied` removes everything in a folder, hidden files and subfolders too, and keeps the folder:
```gherkin
Given the imports folder is emptied
```
It empties a folder of the project, or one a service or an app owns; a folder elsewhere on the machine is a person’s, and the step refuses it. Scenarios run in parallel, so empty a folder only one scenario uses at a time, such as an app’s: an app’s files are its own in each scenario.
## Check a file
[Section titled “Check a file”](#check-a-file)
A check names a file by its path in the folder, with `/` between folders, and without spaces. Every check waits for the file to be there and to meet the check: 10 seconds, or the time `within` gives. A `*` in the path stands for any characters but a slash, for a file named by something a check cannot know, like the time it was saved: `"snap-*.json"` names the one file it matches, and a check waits while none or several do.
```gherkin
Then within 10s the exports folder has a file named manifests/M-HERON-0501/report.csv
And the manifests/M-HERON-0501/report.csv file in the exports folder is identical to the reports/M-HERON-0501.csv file
And the manifests/M-HERON-0501/summary.json file in the exports folder has the following properties:
| manifest | M-HERON-0501 |
| imported | 2 |
| rejected | 1 |
And the manifests/M-WREN-0503/handover.pdf file in the exports folder contains "Parcels to collect: 2"
```
* `is identical to` compares the file byte for byte with a file of the project, found through `resources`.
* `has the following properties:` reads JSON, like the other JSON property steps: a path and the value as text, `null` for null and `undefined` for absent.
* `contains` reads the file’s text, whatever its type (below).
## Check what a service takes away
[Section titled “Check what a service takes away”](#check-what-a-service-takes-away)
A service that imports a file often moves or deletes it, and an app clears what it has dealt with. `has no file named` checks that a file is gone, and `is empty` that a folder has no files, in its subfolders too. Hidden files, like `.DS_Store`, are not counted, and a folder that is not there is empty. Both wait for the files to go: 10 seconds, or the time `within` gives.
```gherkin
Then within 10s the imports folder has no file named incoming/M-HERON-0501.csv
And the imports folder is empty
```
A check that something is gone passes too when nothing happened at all, so check what the service did as well, such as the report it wrote. `axx lint` names the scenarios whose only checks are of what is gone.
## The text of PDF, Word and Excel files
[Section titled “The text of PDF, Word and Excel files”](#the-text-of-pdf-word-and-excel-files)
`contains` reads a file by its type, which its extension tells (or else its content):
| File | Its text |
| -------------------------------------- | -------------------------------------------------------------------- |
| `.pdf` | the text of its pages, line by line, as the pages show it |
| `.docx` | its paragraphs, its tables (a line per row), its headers and footers |
| `.xlsx` | the cells of every sheet, a line per row, as the workbook shows them |
| `.xml`, `.html` | the text between the tags; the markup counts too |
| `.csv`, `.json`, `.txt` and other text | the text itself |
Case matters. Runs of spaces and line breaks count as one space, so a check does not depend on how a PDF spaces its columns:
```gherkin
Then the manifests/M-WREN-0503/handover.pdf file in the exports folder contains "PX-WRN-5201 Marie Curie, 01067 Dresden 1200 g STANDARD"
```
A PDF of scanned images has no text to read, and neither has a PDF that needs a password to open. Older `.doc` and `.xls` files are not read.
## Rows of CSV files and workbooks
[Section titled “Rows of CSV files and workbooks”](#rows-of-csv-files-and-workbooks)
`has a row where:` reads the file as a table whose first row names the columns: a CSV file (with commas or semicolons), a TSV file, or an Excel workbook (its first sheet). One row must have every value of the step’s `| column | value |` table:
```gherkin
Then within 10s the manifests/M-OSPREY-0502/report.xlsx file in the exports folder has a row where:
| line | ML-OSP-0502-2 |
| reference | PX-OSP-5102 |
| status | IMPORTED |
```
Cells compare as text, as the workbook shows them: a price formatted with two decimals is `6.90`, not `6.9`. An empty value matches an empty cell.
## Images, compared with their screenshots
[Section titled “Images, compared with their screenshots”](#images-compared-with-their-screenshots)
`looks like the … screenshot` compares an image a service or an app saves (PNG or JPEG) with its screenshot, pixel by pixel, anti-aliasing aside:
```gherkin
Then within 10s the manifests/M-PLOVER-0504/labels/PX-PLV-5301.png file in the exports folder looks like the "label-PX-PLV-5301" screenshot
```
The first time, there is no screenshot: the step takes it and fails, so that you look at it and keep it (`screenshots/label-PX-PLV-5301.linux.png`). Each platform has its own, named after it, since apps draw their images their own way on each. When the image differs, the step attaches the screenshot, the image and their difference. `packs.files.screenshots` in `axx.yaml` sets their `folder` (default `screenshots`), the share of pixels that may differ (`tolerance`), whether to take them all again (`update`), and the `platforms` a project keeps them for.
## When a check fails
[Section titled “When a check fails”](#when-a-check-fails)
A failed check says what the file holds instead: the lines of its text nearest the text you expected, the rows of its table, or the columns it has.
```text
The manifests/M-HERON-0501/report.csv file in the exports folder did not meet the expectation within 10s: no row has line=ML-HER-0501-3, status=IMPORTED. Its 3 rows are:
line=ML-HER-0501-1, status=IMPORTED
line=ML-HER-0501-2, status=IMPORTED
line=ML-HER-0501-3, status=REJECTED
```
## Objects in cloud storage
[Section titled “Objects in cloud storage”](#objects-in-cloud-storage)
The storage packs, `aws-s3`, `gcp-storage` and `azure-blob`, have the same checks for their objects ([Test cloud services](/next/guides/test-cloud-services/)):
```gherkin
Then within 30s the invoices/kestrel-2026-09.pdf object in the carrier-invoices gcs bucket contains "Total due: 1284.50 EUR"
And the disputes/kestrel-2026-09.csv object in the carrier-invoices gcs bucket has a row where:
| parcel | PX-KES-1001 |
| reason | weight differs |
```
# Check logs
> Prove that something did not happen by asserting the log entry that says so, reading logs from files or receiving them over UDP, TCP or HTTP.
It is hard to prove that something did not happen: waiting and seeing nothing proves only that nothing arrived yet. Flip it to a positive. Have your service log its decision (“registration refused; not announced”, “line ML-KES-0413-1 rejected: duplicate reference”) and assert that entry. The `logs` steps read what your services log, whether it lands in a file or is sent over the network.
## Register a log
[Section titled “Register a log”](#register-a-log)
A log is where a service’s lines are. Register it in the `Background`, with a `url`:
```gherkin
Background:
Given the parcels log with the following properties:
| url | udp://0.0.0.0:5140 |
And the console log with the following properties:
| url | file://.axx/logs/services.log |
```
| url | Axx |
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `file:///var/log/app.log`, `file://logs/app.log` | reads what is appended to the file; a relative path is relative to `axx.yaml` |
| `udp://0.0.0.0:5140` | listens; each datagram is one or more lines |
| `tcp://0.0.0.0:5150` | listens; newline-delimited or octet-counted syslog (RFC 6587) messages |
| `http://0.0.0.0:5160/logs`, `https://0.0.0.0:5161/logs` | listens; the body of each POST or PUT to that path (https uses a self-signed certificate) |
A log only counts what it receives after the scenario registers it. Scenarios run in parallel and share logs, so match on data unique to the scenario: a reference, an order id.
For the network schemes Axx is the log service: your service, or the thing that ships its logs, sends lines to Axx. Axx opens those listeners before it starts the services, for the log steps of the scenarios in the run, so services can connect and send from the moment they start; `axx up` keeps them open between runs. Containers reach them at `host.docker.internal` (on Linux, add `extra_hosts: ["host.docker.internal:host-gateway"]` to the service).
## Point your logs at Axx
[Section titled “Point your logs at Axx”](#point-your-logs-at-axx)
**The console of the services Axx starts** needs nothing: Axx writes it to `.axx/logs/services.log`, every compose container’s lines prefixed with its name, so `file://.axx/logs/services.log` reads it.
**A log file** a service writes, for example to a mounted volume:
infra/compose.yaml
```yaml
app:
volumes:
- ./logs:/var/log/parcels
```
```gherkin
Given the parcels log with the following properties:
| url | file://../infra/logs/app.log |
```
**Syslog over UDP or TCP.** A service’s syslog handler (Python’s `SysLogHandler`, logback’s `SyslogAppender`) can send to `host.docker.internal:5140`, and so can Docker for any container’s console, without changing the service:
infra/compose.yaml
```yaml
app:
logging:
driver: syslog
options:
syslog-address: tcp://host.docker.internal:5150
```
The example’s service sends every log line as a UDP datagram when `PARCELS_LOG_UDP` is set, which is what its `parcels` log receives.
**A log forwarder over HTTP**, such as Fluent Bit or Vector posting to Axx:
infra/fluent-bit.conf
```ini
[OUTPUT]
Name http
Match *
Host host.docker.internal
Port 5160
URI /logs
Format json_lines
```
## Assert entries
[Section titled “Assert entries”](#assert-entries)
Patterns are regular expressions (Java syntax), searched in the log’s text rather than matched against whole lines. Every step waits for its entries: 10 seconds, or the time `within` gives.
```gherkin
Then the parcels log has an entry matching 'msg="registration refused; not announced" reference=PX-EVT-4003'
Then within 30s the parcels log has an entry matching 'depot notified reference=PX-DSP-2001'
```
Several entries in one log; each row needs a match of its own, so the same pattern twice needs two matches:
```gherkin
Then the console log has entries matching:
| msg="manifest line processed" line=ML-FJORD-0101-1 status=REJECTED reason="weight exceeds 30000 g" |
| msg="manifest line processed" line=ML-FJORD-0101-2 status=REJECTED reason="unknown service level" |
```
A number of matches, for example one per retry; more than that fails:
```gherkin
Then the parcels log has 2 entries matching 'msg="storing parcel failed, retrying" reference=PX-DBF-3002'
```
Entries in different logs, for example your service’s decision and the call its dependency received:
```gherkin
Then the logs have entries matching:
| parcels | msg="registration refused; not announced" reference=PX-ADR-1104 |
| console | GET /v1/postcodes/DE/00012 |
```
## Multi-line entries
[Section titled “Multi-line entries”](#multi-line-entries)
Patterns match across the whole text, so an entry can span lines, such as a stack trace. `^` and `$` match at the start and end of each line, `\n` matches a line break, and `(?s)` lets `.` match line breaks too:
```gherkin
Then the parcels log has an entry matching '(?s)registration failed reference=PX-7.*IllegalStateException: address service unavailable'
```
In a table cell, Gherkin turns `\n` into a real line break, which matches a line break just the same.
A log that gathers several services’ output, such as `.axx/logs/services.log`, can put another service’s lines between the lines of an entry, since each service writes on its own. There, match a single line of the entry.
When a step fails, it shows what the log received since the scenario registered it, so you can see what your service said instead.
# Configure services
> Register the REST APIs, mocks, databases and brokers a scenario talks to, name them, and keep URLs and credentials out of feature files.
A **service** is something a scenario talks to: your REST API, a WireMock server, a SQL or MongoDB database, a Kafka cluster. Scenarios register services by name, usually in a `Background`, and later steps use them.
## Register services in a Background
[Section titled “Register services in a Background”](#register-services-in-a-background)
```gherkin
Feature: Register parcels
Background:
Given the parcels service with the following properties:
| url | http://localhost:8400 |
| openapi | http://localhost:8400/openapi.json |
And the mocked addresses service with the following properties:
| url | http://localhost:8081 |
And a parcels-db database with the following properties:
| url | postgres://localhost:5432/parcels |
| user | parcels |
| password | parcels |
And a tracking-db mongo database with the following properties:
| url | mongodb://localhost:27017/parcels?authSource=admin |
| user | parcels |
| password | parcels |
And the events kafka service with the following properties:
| brokers | localhost:9092 |
```
Each kind of service has its own registration step and properties, listed on its pack’s page: [REST](/next/references/packs/rest/) (an `openapi` property turns on [validation](/next/guides/validate-openapi/)), [Mocks](/next/references/packs/mock/), [SQL](/next/references/packs/sql/), [MongoDB](/next/references/packs/mongo/) and [Kafka](/next/references/packs/kafka/).
## The default service and named services
[Section titled “The default service and named services”](#the-default-service-and-named-services)
The first service of a type registered in a scenario is the default. Steps without a service name use it:
```gherkin
Given a GET request to /api/parcels/PX-REG-1001
When the request is executed
Then the response status code is 200
```
When a scenario uses two services of the same type, name the one you mean with the `on ` form of the step:
```gherkin
Given a GET request to /api/parcels/PX-REG-1001 on parcels
When the request is executed on parcels
Then the response status code is 200 on parcels
```
Most steps that address a service have both forms (a few mock steps always name it); see [How steps read](/next/references/steps/#services).
## Keep values out of feature files
[Section titled “Keep values out of feature files”](#keep-values-out-of-feature-files)
Values in service tables and in `axx.yaml` are interpolated ([syntax](/next/references/config/#interpolation)). Put what differs between machines, and every secret, in properties and environment variables:
axx.yaml
```yaml
properties:
local.host: localhost
db.password: ${env:PARCELS_DB_PASSWORD:-parcels}
```
```gherkin
Given a parcels-db database with the following properties:
| url | postgres://${sys:local.host}:5432/parcels |
| user | parcels |
| password | ${sys:db.password} |
```
Override a property for one run with `axx run -D local.host=docker`.
## Differences between environments
[Section titled “Differences between environments”](#differences-between-environments)
Use a **profile** for settings that differ in CI or on another machine:
axx.yaml
```yaml
profiles:
ci:
properties:
local.host: docker
```
Select it with `axx run --profile ci` or `AXX_PROFILE=ci`. A profile can also live in its own file, and personal overrides in an `axx.local.yaml`; [Finding and merging files](/next/references/config/#finding-and-merging-files) has the order.
## Resource paths
[Section titled “Resource paths”](#resource-paths)
Steps that take a file (`seeds/manifest-kestrel.yaml`, `kafka/scan-delivered.json`, `schemas/depot-scan.avsc`) look for it next to the feature file first, then in the `resources` directories in order, then in the directory of `axx.yaml`. A feature keeps the files only it uses beside it ([Organize a suite by feature](/next/guides/organize-by-feature/)), and files several features share go in a `resources` directory:
axx.yaml
```yaml
resources: [acceptance, ../shared-fixtures]
```
If a file is not found, the step fails with [`AXX-E0301`](/next/references/error-codes/#axx-e0301) and lists the directories it searched.
# Debug failures
> Go from a red axx run to the cause - read the exit code, rerun one scenario, explain a step, read expected and actual values, find service logs, and fix flaky parallel scenarios.
## Start from the exit code
[Section titled “Start from the exit code”](#start-from-the-exit-code)
| Code | What happened | First move |
| ----- | ------------------------------- | ----------------------------------------------------------------------------------------- |
| `1` | a scenario failed | read the failing step’s expected and actual values |
| `2` | usage or configuration error | read the `hint`; config errors point at `axx.yaml:line:col`; run `axx doctor` |
| `3` | undefined or ambiguous step | `axx validate`, then `axx explain ""` |
| `4` | a service did not start or stop | the error shows the service’s last output lines; the full log is `.axx/logs/services.log` |
| `130` | interrupted | the run was cancelled (Ctrl-C); run again |
Every error also has a stable code (`AXX-E0408`, say) that links to its entry in the [error code reference](/next/references/error-codes/).
## Read the failure
[Section titled “Read the failure”](#read-the-failure)
```console
$ axx run
...
Scenario: The service says hello # features/smoke/smoke.feature:7
✓ Given the hello-axx service with the following properties: (background)
| url | http://${sys:local.host}:8000 |
✓ Given a GET request to /hello.json
✓ When the request is executed
log: GET http://localhost:8000/hello.json -> 200 OK (2ms, 26 bytes)
attachment: response body (application/json, 26 B)
{"message": "Hello, axx"}
✓ Then the response status code is 200
✗ And the response payload property message is 'Hello, world'
Response payload property message is not Hello, world
expected: "Hello, world"
actual: "Hello, axx"
Failed scenarios:
✗ The service says hello # features/smoke/smoke.feature:7
rerun: axx run features/smoke/smoke.feature:7
```
Each failure names the step, the expected and actual values, and the command that reruns only that scenario. Steps that talk to a service attach what they sent and received. For the same failure as data, with the last request and response, use `axx run --json` ([the run report](/next/references/json-output/#the-run-report)).
## Rerun one scenario
[Section titled “Rerun one scenario”](#rerun-one-scenario)
```sh
axx up # keep the services running while you iterate
axx run features/register-parcels.feature:17 # the scenario on (or containing) line 17
```
The line of any step inside the scenario works too.
## See what a web scenario did
[Section titled “See what a web scenario did”](#see-what-a-web-scenario-did)
A failed scenario of the [`web-core` pack](/next/guides/test-web-apps/) keeps a Playwright trace: each step, its actions, the page before and after them, and the network. In the editor, open it from the scenario’s test ([Open traces and videos](/next/guides/set-up-your-editor/#open-traces-and-videos)).
To look at the page where the scenario fails, run it again with *Debug* in the editor: it pauses there, in Playwright’s Inspector ([Pause a scenario](/next/guides/watch-web-browsers/#pause-a-scenario)). From a terminal:
```sh
axx run --workers 1 --set packs.web-core.pauseOnFailure=true features/shop-portal.feature:21
```
*Watch* shows the page as the scenario runs, without pausing ([Watch a run](/next/guides/watch-runs/)).
## Undefined and ambiguous steps
[Section titled “Undefined and ambiguous steps”](#undefined-and-ambiguous-steps)
```console
$ axx explain 'the mocked request named postcode-check was received 2 times'
undefined: no step matches
did you mean:
the mocked request named {word} was received exactly {int} time(s) (mock.count.exactly)
the mocked request named {word} was received at least {int} time(s) (mock.count.atLeast)
the mocked request named {word} was received at most {int} time(s) (mock.count.atMost)
...
search all steps with `axx steps search `
```
* **Undefined**: the text differs from every step. Use the suggestion or `axx steps search`. Look for extra spaces, `a` versus `an`, singular versus plural, and missing quotes around `{string}` values. An ordinal goes where each step has it: `the 2nd ordered response status code is 200`, but `the response payload property status is 'REGISTERED' for 2nd ordered response`; a line with it in the other place gets the right one as its first suggestion. Never change a step definition to match your text.
* **Ambiguous**: two steps match. Make the line more specific, usually by naming the service with `on `.
## Common causes
[Section titled “Common causes”](#common-causes)
* **Expected X, got Y, and the product looks right.** Another scenario running at the same time may have used the same ids. Check that the data is unique (`axx lint`), then check the value’s type: the step’s entry on its [pack’s page](/next/references/packs/) says how it reads values (`'5'` and `'"5"'` differ).
* **OpenAPI validation error on `the request is executed`.** The request or the response violates the document; the message names the rule, such as `validation.response.body.schema.required`. Fix the payload or the service. For a deliberate negative test, relax that rule in the scenario ([Validate against OpenAPI](/next/guides/validate-openapi/#validation-levels)).
* **Timeout.** A step exceeded `run.timeouts.step`. For asynchronous behavior use a polling step (`within 10s a selection of at least 1 row ...`) instead of a sleep or a longer timeout.
* **Passes alone, fails in the full run.** Shared data or shared state. Make the data unique; if the scenario really must run alone, tag it for `run.exclusive` ([Run in parallel](/next/guides/parallel-runs/)).
* **A service never becomes ready.** Check `services..ready` (URL, port, timeout). Run `axx up`, then `curl` the health URL yourself, and read `.axx/logs/services.log`.
## Debug the service itself
[Section titled “Debug the service itself”](#debug-the-service-itself)
Set a breakpoint in your service under test and run the scenario against it:
```sh
axx run --attach parcels features/register-parcels.feature:17 # you start parcels from your IDE; axx waits for it
axx run --debug=parcels features/register-parcels.feature:17 # axx starts parcels with its debug command
```
[Manage the services under test](/next/guides/manage-services/#debug-a-service) shows how to configure `services..debug`.
## Stop in step code
[Section titled “Stop in step code”](#stop-in-step-code)
To see exactly what a step does, why it fails, or what your service sent back, set breakpoints in the step’s own Go code, whether it comes from one of Axx’s packs or from your own, and step through it:
```sh
axx run --debug-steps features/register-parcels.feature:17
```
Axx builds itself with debug information (the first time; later runs reuse the build), starts under [Delve](https://github.com/go-delve/delve), Go’s debugger, and waits for a debugger on port 2345 (`--debug-steps=` picks another). The scenario starts when one attaches. Step timeouts are off while you debug, and the exit code is the run’s as usual.
* **GoLand, or IntelliJ IDEA with the Go plugin:** click the gutter icon of a scenario and choose *Debug*. The axx plugin attaches the Go debugger for you. From a terminal run, start the *Debugger: axx-steps* configuration (the axx plugin creates it the first time you debug, and `axx ide intellij` writes it).
* **VS Code with the Go extension:** use the debug button of a scenario in the gutter or the Testing view. To attach to a run you started in a terminal, use *axx: attach to steps* (written by `axx ide vscode`).
* **Anything else:** `dlv connect 127.0.0.1:2345`.
Jump from a step in a feature file to its code with go-to-definition ([Set up your editor](/next/guides/set-up-your-editor/)). It opens the Go code that defines the step when that code is on your machine (your own packs, or Axx built from source), otherwise the step’s entry in a reference page.
There is nothing to install: Axx builds itself and Delve with the Go toolchain it downloads, as it does for packs, and keeps them for later runs. To use a Delve of your own, set `AXX_DLV` to its path.
## Logs
[Section titled “Logs”](#logs)
| File | Contents |
| -------------------------- | --------------------------------------------------------------------------- |
| `.axx/logs/services.log` | output of every service Axx started |
| `.axx/logs/supervisor.log` | what the background process of `axx up` logged; read it when `axx up` fails |
Add `-v` or `-vv` to any command for more detail from Axx itself.
## Don’t
[Section titled “Don’t”](#dont)
* Don’t loosen an assertion to make a test pass. Find out whether the product is wrong first.
* Don’t add fixed sleeps. Use readiness checks and polling steps.
* Don’t edit generated files (`axx-lint.generated.yaml`, generated fixtures); change their source and regenerate.
# Fixture factories
> Generate schema-valid seeds, event payloads and mock bodies from a compact factory spec with axx fixtures, and catch drift when a schema changes.
Suites accumulate dozens of fixture files that share one schema: Kafka payloads for one Avro record, seed datasets for one set of tables, mock bodies for one API. Most of each file is boilerplate, and the day the schema gains a required field, every file needs the same edit. A **factory** keeps the shared shape once and each fixture as only its differences. `axx fixtures` expands them into ordinary files.
## The files
[Section titled “The files”](#the-files)
Everything lives next to the fixtures it produces:
```text
kafka/
depot-scans.factory.yaml # family, schema, identities
depot-scans.prototype.yaml # the shared shape
scan-delivered.fixture.yaml # one fixture: only its differences
scan-out-for-delivery.fixture.yaml
scan-delivered.json # generated
scan-out-for-delivery.json # generated
```
kafka/depot-scans.factory.yaml
```yaml
# yaml-language-server: $schema=https://axx.nimbusxr.us/schemas/v0/axx-factory.schema.json
factory:
family: avro
schema: ../schemas/depot-scan.avsc # relative to this file
identity:
- path: scanId # unique across every fixture
```
kafka/depot-scans.prototype.yaml
```yaml
data:
parcelRef: PX-EXAMPLE-1
location: Leipzig
scannedAt: "2026-05-06T10:15:00Z"
```
kafka/scan-delivered.fixture.yaml
```yaml
data:
scanId: SC-FIXTURE-DELIVERED
status: DELIVERED
```
The fixture’s file name is its name, and `scan-delivered.json` is what features reference, exactly as if you had written it by hand:
kafka/scan-delivered.json (generated)
```json
{
"scanId": "SC-FIXTURE-DELIVERED",
"parcelRef": "PX-EXAMPLE-1",
"status": "DELIVERED",
"location": "Leipzig",
"scannedAt": "2026-05-06T10:15:00Z"
}
```
## Identities
[Section titled “Identities”](#identities)
An `identity:` entry names a field that tells fixtures apart, like `scanId` above. A fixture that gives it a value keeps that value; one that leaves it out gets one derived from its name (`prefix:` goes in front, and `format: uuid-name-based` makes it a UUID). Generation fails when two fixtures, of any factories, have the same value in identity fields of the same name, and names both fixtures.
* **Fields of different names never collide.** A shop’s `key` and a pickup’s `id` can both be `alder-stationery`. So can two identity fields of one fixture: each identity is checked on its own.
* **A namespace groups identities by what they identify.** Give identities a `namespace:` when their field names say too little. Different namespaces keep fields of one name apart: a depot’s `id` and a shop’s `id` can both be `leipzig`. One namespace compares fields of different names: a SQL seed’s `shops.shop_id` and a registration payload’s `shopId` that both create shops.
depots/depots.factory.yaml
```yaml
identity:
- path: id
namespace: depots # a depot's id may equal a shop's
```
Before namespaces, identity values were unique across every identity field of every factory. To keep that, give every identity the same namespace.
## Reference other fixtures
[Section titled “Reference other fixtures”](#reference-other-fixtures)
A value can be another fixture, or part of one, with `$ref`: the path of its `*.fixture.yaml` (or `*.factory.yaml`) file, relative to the file that refers to it (a leading `/` starts at the directory of `axx.yaml`, or `fixtures.baseDir`), then `#` and a JSON Pointer into it.
seeds/handover.fixture.yaml
```yaml
data:
parcel:
$ref: ../parcels/express-berlin.fixture.yaml # the whole fixture
parcelId:
$ref: ../parcels/express-berlin.fixture.yaml#/id # one value of it
```
A reference sees the fixture as it is generated: its prototype, its defaults, and the identities derived for it. Keys next to `$ref` override what it brings. References that loop fail and name the loop; a path that names no fixture says what it was resolved to, and the fixtures it most likely meant.
## Generate and check
[Section titled “Generate and check”](#generate-and-check)
```sh
axx fixtures generate # write the fixture files, the manifest and lint rules
axx fixtures check # CI: regenerate in memory and compare, without writing
```
* **Generation is a development-time step.** Nothing is generated during `axx run`; features cannot tell a generated fixture from a hand-written one.
* **Output is deterministic.** The same spec always produces the same bytes, and every output is validated against the schema (the Avro schema here, so a `status` that is not one of its symbols fails) before it is written.
* **The tool only touches files it owns.** `axx-fixtures.manifest.yaml` records them with their checksums. A managed file that was edited by hand is refused, never overwritten: move the change into the spec.
* **Identities become lint rules.** Generation refuses two fixtures that claim one identity value (`AXX-E0902`), and a fixture that omits one gets a value derived from its name, so it is unique by construction. Each `identity:` entry also produces a rule in `axx-lint.generated.yaml`, which `lint.include` pulls into [`axx lint`](/next/guides/isolate-test-data/), so a hand-written file beside the generated ones that reuses a value fails too. The rule reads the files in the format the factory writes: JSON, YAML, XML, or a seed’s YAML, flat XML or CSV.
When the schema gains a required field, generation fails and names every fixture that lacks it. Add it once to the prototype (or a `defaults:` entry) and regenerate.
## Where a value comes from
[Section titled “Where a value comes from”](#where-a-value-comes-from)
A generated value comes from the fixture, a prototype overlay, the prototype, a `defaults:` entry or the schema’s default, and it may get there through a `$ref`, an expression or an identity. `axx fixtures explain` names the source to edit, and the place in it:
```sh
axx fixtures explain kafka/scan-delivered.json location
```
```text
location = "Leipzig"
in kafka/scan-delivered.json, fixture scan-delivered of kafka/depot-scans.factory.yaml
set by the prototype at data.location in kafka/depot-scans.prototype.yaml
```
The path is dotted, with indices (`recipient.postcode`, `items[0].sku`); a dataset’s starts with its table (`parcels.manifest_lines[0].weight_grams`). A fixture’s `*.fixture.yaml` works in place of the file it generates. With `--json`, the origins come as a list, outermost first: a `$ref`, then the source of what it points at.
A payload the cloud wraps and encodes on its way to your service, such as a Pub/Sub push envelope with the message base64-encoded in it, is not a fixture to build: publish the message itself, and the emulator’s push subscription delivers the envelope ([Send a service messages](/next/guides/test-cloud-services/#send-a-service-messages)).
## Families
[Section titled “Families”](#families)
| Family | Produces | Schema |
| ---------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `avro` | Kafka payload JSON | an `.avsc` file |
| `json` | any JSON: mock bodies, request payloads, messages | JSON Schema, an OpenAPI component (`api.yaml#/components/schemas/Name`), or an AsyncAPI message’s payload (`asyncapi.yaml#/components/messages/Name`) |
| `yaml` | record-shaped YAML | the same as `json` |
| `xml` | XML documents | an XSD |
| `protobuf` | canonical proto-JSON | a `.proto` file or a descriptor set, with the message (`.proto#pkg.Message`, `.desc#pkg.Message`) |
| `dataset` | SQL seeds: YAML datasets, flat XML or CSV directories | optional SQL DDL (a file or a directory of migrations) |
A JSON Schema can reference definitions in other local files, by a path relative to the schema holding the `$ref`, with or without a fragment: `"$ref": "address.schema.json"` or `"$ref": "common/money.schema.json#/$defs/Money"`. A failure a referenced file’s rule causes names that file. Remote schemas are not fetched: save them next to yours.
For `dataset`, the prototype is a row template per table, and identities are `table.column` paths enforced per row. `options: { format: xml }` or `{ format: csv }` in the factory writes flat XML or a CSV directory instead of YAML. The parcels example generates its rejected manifest lines as flat XML:
seeds/manifests/xml/manifests-xml.factory.yaml
```yaml
factory:
family: dataset
options: { format: xml }
schema: ../../../../infra/postgres/init/01-schema.sql
```
## Project settings
[Section titled “Project settings”](#project-settings)
axx.yaml
```yaml
fixtures:
sources: ["../infra/wiremock"] # extra roots, e.g. mock bodies served by a container
output: { ignored: true } # generated files are gitignored, not committed
conformance: # lint hand-written files against a schema too
- name: seeds match the database schema
filePatterns: ["seeds/*.yaml"]
schemaType: dataset
schemaRef: ../infra/postgres/init/01-schema.sql
```
With `output: { ignored: true }`, generated files are derived on demand instead of committed: Axx maintains an exact `.gitignore` in each output directory, and a fresh clone runs `axx fixtures generate` once before `axx run`. The specs, the manifest and the generated lint rules stay committed; they are what reviewers read.
In CI, check before you generate:
```sh
axx fixtures check # the sources, the committed manifest and any generated files agree
axx fixtures generate # write the ignored outputs this checkout lacks
axx run
```
A fresh clone checks clean: the ignored outputs it lacks pass when the committed manifest records what the sources produce. Changed sources whose manifest was not generated again fail, and so does a generated file edited by hand. The other order would hide a stale manifest, because generate brings it up to date.
`conformance` rules check files that no factory owns, so hand-written seeds are held to the same schema.
## Adopt existing files
[Section titled “Adopt existing files”](#adopt-existing-files)
You do not need to write specs for fixtures you already have. Adoption reads existing files, puts the values they all share into the prototype, keeps each file’s differences in its own `*.fixture.yaml`, and proposes identity candidates for you to review. It only writes anything if regenerating from the new spec reproduces the originals exactly.
Try it first with `--dry-run`, which does everything but write:
```sh
axx fixtures adopt --family json --schema openapi/parcels.yaml#/components/schemas/Parcel \
--files 'mocks/parcels/*.json' --factory parcel-bodies --dry-run
```
```text
decoded 3/3 files (family: json)
prototype: 4 field(s) (the values every adopted file shares)
identity candidates, not declared: id (distinct in all 3), trackingNumber (distinct in all 3); declare the ones that identify a fixture with --identity
would write mocks/parcels/parcel-bodies.factory.yaml + 3 *.fixture.yaml + parcel-bodies.prototype.yaml
semantic round-trip: 3/3 deep-equal. 0 file(s) will be reformatted by `axx fixtures generate`, 0 semantic changes.
next: axx fixtures generate
```
* **The prototype takes only what every file shares.** A value two files of three happen to have stays in those two files, so no fixture’s customer or tenant becomes every new fixture’s default. `--prototype common` takes each field’s most common value instead.
* **Identities are yours to declare.** A candidate is a string field whose value is distinct in every file, which is what an identity looks like, but not proof of one: an email or a domain can be distinct in three examples by chance. Declare the ones that identify a fixture with `--identity` (repeatable); the factory file lists the others as comments.
* **A refusal says what would change.** When regenerating would not reproduce the originals, adoption lists the paths that would differ and writes nothing. Seed layouts whose maps are keyed by document ID (`orgs: {alpha: {...}}`) often do this: adopt the records as a factory of their own, then reference them from the layout with `$ref`.
```sh
axx fixtures adopt --family json --schema openapi/parcels.yaml#/components/schemas/Parcel \
--files 'mocks/parcels/*.json' --factory parcel-bodies --identity id
axx fixtures adopt --into parcel-bodies --files 'mocks/returns/*.json' # more files into the same factory
axx fixtures generate
```
`--into` adds files to an existing factory, with its prototype and identities.
# Install Axx
> Install the Axx CLI with Homebrew, the install script, go install or the container image, verify the download and keep it up to date.
Axx is a single static binary for Linux, macOS and Windows on amd64 and arm64. It has no runtime dependencies. Docker is only needed if the services you test start with Docker Compose.
Pre-release
Axx is in beta. Every `0.x` release is marked as a pre-release on GitHub.
## Choose a channel
[Section titled “Choose a channel”](#choose-a-channel)
### Homebrew (macOS, Linux)
[Section titled “Homebrew (macOS, Linux)”](#homebrew-macos-linux)
```sh
brew install nimbusxr/tap/axx
```
### Install script (Linux, macOS, CI)
[Section titled “Install script (Linux, macOS, CI)”](#install-script-linux-macos-ci)
```sh
curl -fsSL https://axx.nimbusxr.us/install.sh | sh
```
The script picks the newest release (pre-releases included, since every `0.x` release is one), downloads the archive for your OS and architecture, and checks it against `checksums.txt`. It installs `axx` in `/usr/local/bin` when that is writable, otherwise in `~/.local/bin`. Set `AXX_INSTALL_DIR` to choose the directory, and `AXX_VERSION` to install a given version (`0.1.0`) or the `nightly` build.
### Install script (Windows)
[Section titled “Install script (Windows)”](#install-script-windows)
```powershell
irm https://axx.nimbusxr.us/install.ps1 | iex
```
The script installs the newest release in `%LOCALAPPDATA%\axx\bin`, after checking it against `checksums.txt`, and adds that directory to your user `PATH`.
### Go
[Section titled “Go”](#go)
```sh
go install github.com/nimbusxr/axx/cmd/axx@latest
```
Needs Go 1.27 or newer. The binary lands in `$(go env GOPATH)/bin`.
### Container image
[Section titled “Container image”](#container-image)
```sh
docker run --rm -v axx-cache:/home/nonroot -v "$PWD:/work" -w /work ghcr.io/nimbusxr/axx validate
```
The image contains only the `axx` binary (on a distroless base). The `axx-cache` volume keeps the packs Axx prepares for the project ([Choose packs](/next/guides/use-packs/)); without it, every run prepares them again. It suits commands that do not start services (`validate`, `steps`, `explain`, `schema`) and runs against services that are already up (`axx run --no-start`). It cannot run `docker compose` for you.
### Release archives
[Section titled “Release archives”](#release-archives)
Download `axx___.tar.gz` (`.zip` on Windows) from [GitHub releases](https://github.com/nimbusxr/axx/releases), extract `axx` and put it on your `PATH`. A rolling `nightly` pre-release is built from `main` every day.
## Verify the download
[Section titled “Verify the download”](#verify-the-download)
Release checksums are signed with Sigstore (keyless), and archives carry an SBOM and build provenance.
```sh
cosign verify-blob checksums.txt \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github.com/nimbusxr/axx/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
sha256sum --ignore-missing -c checksums.txt
gh attestation verify axx_0.1.0_linux_amd64.tar.gz --repo nimbusxr/axx
```
## Check the installation
[Section titled “Check the installation”](#check-the-installation)
```sh
axx version
axx doctor
```
`axx doctor` checks the binary, `axx.yaml`, the step packs, your feature files and the commands your services need. It exits `0` when nothing failed (warnings are allowed) and `4` otherwise.
## Shell completion
[Section titled “Shell completion”](#shell-completion)
```sh
axx completion zsh > "${fpath[1]}/_axx" # zsh
axx completion bash > /etc/bash_completion.d/axx # bash
axx completion fish > ~/.config/fish/completions/axx.fish
```
## Editors
[Section titled “Editors”](#editors)
To see Axx’s steps in feature files as you write them, set up IntelliJ IDEA, VS Code or another editor with [Set up your editor](/next/guides/set-up-your-editor/).
## Upgrade
[Section titled “Upgrade”](#upgrade)
Upgrade with the channel you installed from (`brew upgrade`, rerun the script, or `go install ...@latest`). Step text never changes between versions, so feature files keep working. Before `v1.0.0`, a minor version bump (`0.1` to `0.2`) may change a flag or a configuration key; read the [changelog](https://github.com/nimbusxr/axx/blob/main/CHANGELOG.md) first.
After upgrading, refresh the agent skills so their step index matches the new binary:
```sh
axx skills install
```
# Isolate test data
> Use axx lint to guarantee that ids, keys and names in seeds, payloads and features never collide, so scenarios can share infrastructure and run in parallel.
Scenarios run in parallel against one database, one broker and one set of mocks. They stay independent only if each one owns its data. Two seed files that both insert the parcel `PX-KES-1001`, or two scenarios that both publish events keyed `PX-TRK-3002`, produce failures that come and go with scheduling. `axx lint` finds those collisions before a run does.
## Write rules
[Section titled “Write rules”](#write-rules)
Rules live under `lint:` in `axx.yaml`. Each one extracts values from files and says how unique they must be:
axx.yaml
```yaml
lint:
config:
baseDir: . # patterns are relative to this directory
mode: error # error fails the lint; warn only reports
rules:
- name: Parcel references in seeds
filePatterns: ["seeds/*.yaml"]
regex: '^\s+-?\s*reference:\s*"([^"]+)"'
description: Every seeded parcel and manifest line has its own reference
validation: cross-file-unique
- name: Manifest line ids
filePatterns: ["seeds/*.yaml"]
regex: '^\s+-?\s*id:\s*"([^"]+)"'
validation: cross-file-unique
- name: Depot scan ids
filePatterns: ["seeds/*.json"]
type: jsonpath
jsonPath: "scans[*].scanId"
validation: cross-file-unique
```
A `jsonpath` rule reads each file by its extension, as JSON, YAML, XML or CSV, so it needs no pattern for the file’s layout. A seed’s rows read the same in YAML, flat XML and CSV: `$['parcels.manifest_lines'][*]['id']` is the `id` of every manifest line, whichever format the seed is in.
Each rule key is described in the [configuration reference](/next/references/config/#lint). A rule that scans files but extracts no value from them checks nothing, so `axx lint` warns about it instead of reporting ok: most often its pattern misses the files’ format, like a regex anchored at `reference:` for YAML list items, whose lines start with `- `(the rules above allow it with `-?`).
Generated rules can be merged in with `include`:
axx.yaml
```yaml
lint:
include: [axx-lint.generated.yaml] # written by `axx fixtures generate`
```
## One scenario, several files
[Section titled “One scenario, several files”](#one-scenario-several-files)
A `cross-file-unique` rule compares file with file: a value two files share is a collision. That holds while each scenario’s data is in a file of its own. Some scenarios need more than one file. The couriers’ app’s list refresh signs a courier in with one parcel, then gives the same courier a second parcel while the scenario runs, from a second seed file. Both files name the courier, and one scenario owns it.
Such a scenario keeps its files in a folder of its own, named after it, and the rule names that folder with a pattern that ends in `/`. All the files below a folder that pattern matches count as one file: they may share values with each other, and with nothing else.
```plaintext
features/courier-app/seeds/
courier-app-today.yaml one scenario's seed: one file
courier-app-refresh/ one scenario's seeds: one folder, counted as one file
at-sign-in.yaml
given-since.yaml
```
axx.yaml
```yaml
lint:
rules:
- name: Couriers in seeds
filePatterns:
- "features/*/seeds/*.yaml" # each scenario's file
- "features/*/seeds/*/" # each scenario's folder, as one file
regex: '"courier": "([^"]+)"'
description: Every courier's parcels are one scenario's
validation: cross-file-unique
```
```gherkin
Scenario: A courier pulls the list down for a parcel given to them since
Given a seeds/courier-app-refresh/at-sign-in.yaml db seed
# ...
When a seeds/courier-app-refresh/given-since.yaml db seed
```
A courier in `courier-app-refresh/` and in `courier-app-today.yaml` is reported as two files sharing a value (`a folder's files count as one`), and so is a courier two folders share.
* **One folder, one scenario.** A folder says its files are one scenario’s, and the rule takes its word: two scenarios’ files in one folder may share values unnoticed. A folder the pattern matches that is no scenario’s, like one that keeps the fixtures a [factory](/next/guides/fixture-factories/) generates, goes in `excludePatterns` (`"features/manifest-import/seeds/manifests/**"`); its own rules check it.
* **A folder pattern takes every file below the folder**, whatever its kind; `excludePatterns` remove the ones the rule should not read.
* **Every rule over the seeds names the folders.** A rule without the folder pattern checks the folder’s files one by one, or not at all when its file pattern does not reach them (`features/*/seeds/*.yaml` does not).
* **A folder claims its files.** A file a file pattern matches too (`seeds/*.yaml` reaches into subfolders) still counts as its folder’s; folders inside a matched folder are part of it.
* `cross-file-unique` and `file-unique` count the folder as one file; `global-unique` counts every occurrence, as always. `excludePatterns` still remove files one by one.
## Run it
[Section titled “Run it”](#run-it)
```sh
axx lint
```
```console
FAIL Parcel references in seeds (cross-file-unique, 52 files): 1 duplicate value
Every seeded parcel and manifest line has its own reference
value "PX-KES-1001" appears in 2 files (cross-file-unique) [AXX-E0820]
seeds/manifest-kestrel-resend.yaml:4:17 reference: "PX-KES-1001"
seeds/manifest-kestrel.yaml:4:17 reference: "PX-KES-1001"
ok Manifest line ids (cross-file-unique, 52 files)
ok Depot scan ids (cross-file-unique, 1 file)
ok kafka/depot-scans.factory.yaml: scanId uniqueness (cross-file-unique, 2 files)
ok requests/registrations.factory.yaml: reference uniqueness (cross-file-unique, 2 files)
ok Services registered before use (13 files)
ok SQL selection and trigger ordinals (13 files)
ok Stale selections (13 files)
ok REST request ordinals (13 files)
ok REST payload values (13 files)
hint: seeds/ has 39 hand-written .yaml files of one shape (parcels.parcels) that no fixture factory generates; a factory would keep what they share in one place (optional; `axx fixtures adopt --help`)
hint: seeds/ has 5 hand-written .yaml files of one shape (parcels.manifest_lines) that no fixture factory generates; a factory would keep what they share in one place (optional; `axx fixtures adopt --help`)
axx lint: 10 rules, 70 files: 1 error, 0 warnings
```
A new seed file reused a reference that `seeds/manifest-kestrel.yaml` already inserts. The rules from `axx-lint.generated.yaml` run too, and so do the built-in checks of the features. They catch:
* a When after a Then: a scenario that does something and checks it, then does something else and checks that, which is several scenarios in one (write one per behavior, with what an earlier round did as Given steps);
* a REST, SQL, MongoDB or Kafka step that comes before the scenario registers a service of its pack;
* SQL selections and triggers, and REST requests, addressed by ordinals they cannot have (a `3rd ordered` request after only one, or a second request added without its ordinal);
* a step without an ordinal that checks the first selection while a later one goes unchecked (`the selection has 1 row` after a second retrieval);
* request payload values in single quotes, which in a table are part of the value.
They are warnings, and `axx validate` reports them as well.
The `hint:` lines are suggestions, never errors or warnings. When the scenarios read three or more hand-written `.json` or `.yaml` files from one directory, whose top-level keys are the same and that no [fixture factory](/next/guides/fixture-factories/) generates, `axx lint` suggests one: it would keep what the files share in one place and each file’s differences in its own. Factories are optional; `axx fixtures adopt` turns the files into one without changing a value.
`axx lint`, `axx validate` and a passing `axx run` also hint at scenarios whose checks prove little: those that check only a success status (a 2xx response, or a command’s exit code 0), which says it was accepted, not what it did, and those that check only that something did not happen, which also passes when the action never ran. A hint names the scenarios; whether one needs another check is for its author to judge.
`axx lint` exits with `3` when an `error`-mode rule finds a duplicate, like `axx validate` does for undefined steps. Run it in CI next to `axx validate`, and start new rules in `warn` mode while you clean up existing data. `axx lint --mode error` makes every rule an error, the built-in checks of the features too, so CI fails on an ordinal that would fail every run, before any service starts.
## Tips
[Section titled “Tips”](#tips)
* **Prove a rule bites.** After writing a rule, plant a duplicate value and check that `axx lint` fails. A pattern that matches no files still passes (its line only says `no files matched`).
* **Name values after the scenario.** `PX-DBF-3001` tells a reader which feature owns it; `test` tells nobody anything.
* **Declare identities once.** With [fixture factories](/next/guides/fixture-factories/), an `identity:` in the factory spec generates the matching lint rule for you.
* **Some scenarios cannot share.** A scenario that changes a table’s triggers affects everyone. Tag it with a tag from `run.exclusive` so it runs alone ([Run in parallel](/next/guides/parallel-runs/)).
[Scenario isolation](/next/explanations/scenario-isolation/) explains the reasoning behind shared infrastructure with isolated data.
# Manage the services under test
> Declare how Axx starts, waits for, stops and cleans up the services under test, keep them running with axx up, attach services you run yourself, and start only the services a run needs.
`services:` in `axx.yaml` describes the system under test: your service and anything it needs, such as databases, brokers and mocks. `axx run` starts them, waits until they are ready, runs the scenarios, stops them and runs their cleanup.
A service is what runs on its own and waits for whoever connects to it, by an address. A program its user starts, uses and ends is not a service, even when it is what you test: a command-line tool, or an MCP server its clients run over stdio. The step that uses it runs it, as its user would: the cli pack’s and the mcp pack’s `command`.
## Declare a service
[Section titled “Declare a service”](#declare-a-service)
axx.yaml
```yaml
services:
infra:
dir: ./infra # relative to axx.yaml
command: docker compose up --wait postgres kafka
ready:
tcp: localhost:5432
cleanup: docker compose down -v --remove-orphans
api:
dependsOn: [infra]
command: ./gradlew bootRun # or npm start, go run ./cmd/api, ...
env:
SPRING_PROFILES_ACTIVE: acceptance
ready:
http:
url: http://localhost:8080/actuator/health
timeout: 120s
interval: 1s
stop:
signal: SIGTERM
grace: 20s
```
Each key is described in the [configuration reference](/next/references/config/#services). A command that exits `0` before the service is ready is fine, which is what `docker compose up -d` or `--wait` does: Axx keeps polling the `ready` checks.
If you also run a compose file yourself while you develop, give the stack Axx starts from it a name of its own, in `command` and `cleanup` alike: `docker compose -p parcels-axx up`. Compose otherwise names both stacks after the folder, so Axx’s takes over yours, and `down -v` deletes your stack’s volumes, your local data with them. `axx init` names the stack it writes so.
Service output goes to `.axx/logs/services.log`. When a service fails to start, the error shows the last lines of its output and a stable code from [`AXX-E0400` to `AXX-E0415`](/next/references/error-codes/#service-lifecycle).
## Keep services running between runs
[Section titled “Keep services running between runs”](#keep-services-running-between-runs)
```sh
axx up # start every enabled service (or: axx up api) and wait until ready
axx run # reuses the running services: no start, no stop
axx run --tags @smoke
axx env # which services are running, left over from a killed run, or not cleaned up
axx down # stop the services and run their cleanup
```
This is the fastest local loop, and the one agents should use. The services keep their data from run to run, and `axx run` warns so when it reuses them: a scenario that creates something (registers a parcel by its reference, say) finds what an earlier run created and fails where a fresh start would pass. `axx down`, then `axx up`, starts them afresh; check a change on fresh services before you trust its result.
## When a run cannot start them
[Section titled “When a run cannot start them”](#when-a-run-cannot-start-them)
An agent whose sandbox keeps it from Docker cannot start services that run with `docker compose`. `axx host`, started in the project on your machine, starts them for its runs: each run asks the host for its services, the host starts them as `axx.yaml` says and cleans up after the run, and it starts only a definition you approved. [Run tests from an agent’s sandbox](/next/guides/run-from-a-sandbox/) shows how.
## When a run is stopped by force
[Section titled “When a run is stopped by force”](#when-a-run-is-stopped-by-force)
A run that is killed before it can stop its services (stopped by force from an editor, say, or crashed) leaves them in `.axx/run/state.json`. The next run cleans up first, as `axx down` does: it stops what is left, runs the cleanups, says so, and starts from a clean slate. What belongs to a run still going, or to `axx up`, is left alone: a run then starts no service and says what is left (`AXX-E0415`).
## When a cleanup fails
[Section titled “When a cleanup fails”](#when-a-cleanup-fails)
A cleanup can fail: `docker compose down` without access to Docker, say. The run reports it (`AXX-E0411`), and `.axx/run/state.json` keeps the cleanup, so what it should have removed is not forgotten. Until it succeeds:
* runs and `axx up` try it again first, and start no service while it fails, saying what is left (`AXX-E0415`): containers, volumes and requests recorded by a mock would be where the next run starts from;
* `axx down` runs the cleanup again, and says `cleaned up after an earlier run` once it succeeds;
* `axx doctor` lists what is left, and so do `axx env` and the MCP `env` tool’s `status`, with each service `running`, `left over` from a run that was killed, or `not cleaned up`.
## Run a service yourself
[Section titled “Run a service yourself”](#run-a-service-yourself)
To run the service from your IDE (with breakpoints, hot reload, a profiler), tell Axx not to start it. Axx still starts everything else and waits for its readiness checks:
```sh
axx run --attach api
```
`--no-start` skips starting, stopping and cleaning up every service; use it when the whole system is already running somewhere.
## Debug a service
[Section titled “Debug a service”](#debug-a-service)
Give the service a debug command and a debugger to wait for:
axx.yaml
```yaml
services:
api:
command: ./gradlew bootRun
debug:
command: ./gradlew bootRun -PappJvmArgs=-agentlib:jdwp=transport=dt_socket,server=n,address=localhost:5005,suspend=n
debugger:
type: java # java, go, nodejs or python
port: 5005
mode: ide-listens # the IDE listens, the service connects (default for java)
onUnavailable: retry # retry (default), fail, or fallback to the normal command
retry: {attempts: 10, delay: 3s}
```
```sh
axx run --debug # every service with a debug block
axx run --debug=api # only api
axx up --debug=api
```
With `mode: ide-listens`, start a listening debugger in your IDE first (in IntelliJ IDEA: a *Remote JVM Debug* configuration in *Listen to remote JVM* mode on port 5005), then run Axx. With `mode: app-listens` (delve, `--inspect`, debugpy), the service opens the port and you attach; the [parcels example](https://github.com/nimbusxr/axx/tree/main/examples/parcels) runs its Go service under Delve this way. If no debugger is listening, `onUnavailable` decides whether to wait, fail with [`AXX-E0409`](/next/references/error-codes/#axx-e0409), or run without debugging.
## Start only what a run needs
[Section titled “Start only what a run needs”](#start-only-what-a-run-needs)
In a repository with several services, start only the ones the selected scenarios need:
axx.yaml
```yaml
active:
enabled: true
onNoTags: fallback # no selected scenario has a tag: start every enabled service (or: error)
services:
orders:
command: ./gradlew :orders:bootRun
active: {tags: ["@orders"]}
billing:
command: ./gradlew :billing:bootRun
active: {tags: ["@billing", "@payments"]}
wiremock:
command: docker compose up wiremock # no active.tags: always starts
```
A service with `active.tags` starts when any selected scenario carries one of its tags, and the services in its `dependsOn` start with it. Services without `active.tags` always start. `axx run --tags @billing` starts `billing` and `wiremock`, not `orders`.
A service can also wait for what the scenarios use, with `active.uses`: it starts when a selected scenario uses a step of one of its packs, no tag needed. A service only the mobile apps call, say:
axx.yaml
```yaml
services:
push-gateway:
command: docker compose up push-gateway
active: {uses: [mobile-android, mobile-ios]}
```
# Mock dependencies
> Replace the services your service calls with WireMock, verify the requests it sent, and check both sides of each dependency's OpenAPI contract with the Axx WireMock image.
Your service calls other services. In an acceptance test you replace them with [WireMock](https://wiremock.org/) mocks, which gives you two things: the responses are under your control, and you can check exactly what your service sent, down to the signatures of its webhooks ([Test webhooks and tokens](/next/guides/webhooks-and-tokens/)).
## Define the stubs
[Section titled “Define the stubs”](#define-the-stubs)
Stubs are ordinary WireMock mapping files. Axx does not create stubs; it verifies traffic. Mount the mappings into a WireMock container:
infra/wiremock/mappings/postcode-undeliverable.json
```json
{
"name": "postcode-undeliverable",
"priority": 1,
"request": {
"method": "GET",
"urlPathPattern": "/v1/postcodes/[A-Z]{2}/999[^/]*"
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"body": "{\"postcode\": \"{{request.pathSegments.[3]}}\", \"country\": \"{{request.pathSegments.[2]}}\", \"deliverable\": false, \"reason\": \"no delivery to this postcode\"}",
"transformers": ["response-template"]
}
}
```
infra/compose.yaml (excerpt)
```yaml
services:
address-service:
image: wiremock/wiremock:3.13.0
ports: ["8081:8080"]
volumes: ["./wiremock:/home/wiremock"]
```
Configure your service to call `http://localhost:8081` (or the compose service name) instead of the real dependency.
## Verify what your service sent
[Section titled “Verify what your service sent”](#verify-what-your-service-sent)
Register the mock in the scenario, trigger the behavior, then check the requests WireMock received:
```gherkin
Feature: Address check
Background:
Given the parcels service with the following properties:
| url | http://localhost:8400 |
And the mocked addresses service with the following properties:
| url | http://localhost:8081 |
Scenario: The address service is asked with the API key
Given a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload properties are:
| reference | PX-ADR-1101 |
| recipient.postcode | "53111" |
When the request is executed
Then the response status code is 201
And the mocked GET request to /v1/postcodes/DE/53111 named postcode-check was received by addresses
And the mocked request named postcode-check was received exactly 1 time
And the header X-Api-Key for mocked request named postcode-check on addresses is 'example-address-key'
```
`the mocked GET request to /v1/postcodes/DE/53111 named postcode-check was received by addresses` registers a request pattern (method and exact URL, including the query string) under a name you choose, and checks it was received at least once. Later steps refer to it by that name; the [mock pack reference](/next/references/packs/mock/) has the ones that check counts, absence and headers.
```gherkin
Scenario: Invalid registrations never reach the address service
Given the OpenAPI validation levels are:
| validation.request.body.schema.minimum | IGNORE |
And a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload properties are:
| reference | PX-ADR-1103 |
| weightGrams | 0 |
| recipient.postcode | "12489" |
When the request is executed
Then the response status code is 400
And the mocked GET request to /v1/postcodes/DE/12489 named skipped-check was not received
```
### What a request sent
[Section titled “What a request sent”](#what-a-request-sent)
When requests share a URL, like every courier booking going to `/v1/collections`, check what a request sent: the properties of its JSON body, or the fields of its form. The checks stay with the named request, so its counts count only the requests that sent that:
```gherkin
Scenario: The courier collects an express parcel without being told who it goes to
Given the mocked courier service with the following properties:
| url | http://localhost:8082 |
And a POST request to /api/parcels
And a request payload using an application/json content example named 'Express parcel'
And the request payload property reference is 'PX-REG-1401'
When the request is executed
Then the response status code is 201
And the mocked POST request to /v1/collections named collection was received by courier
And the payload properties for mocked request named collection on courier are:
| reference | PX-REG-1401 |
| deliverTo.postcode | "75001" |
| recipient | undefined |
And the mocked request named collection on courier was received exactly 1 time
```
* **A property is a JSONPath** (`deliverTo.postcode`, `$.lines[0].reference`), and values compare as text: `800` matches the number 800, `"75001"` the string.
* **`undefined` checks that something was left out**, such as a recipient’s name the courier must never get.
* **Form fields** of a form-encoded body are checked the same way: `the form fields for mocked request named pickup-notice are:`, with a field and its value a row.
When a request’s query changes from call to call, such as a request ID, name the request by its path, and check the query parameters that matter:
```gherkin
Then the mocked POST request to path /v1/collections named collection was received by courier
And the query parameters for mocked request named collection on courier are:
| slot | same-day |
| reference | undefined |
```
A request named by its path matches whatever its query string; its query parameters narrow it, and its counts, like its body’s properties.
### What your service did not send
[Section titled “What your service did not send”](#what-your-service-did-not-send)
A check that something did not happen also passes when the service never got that far, so check what it did do too, like the response. Every scenario’s requests count, so the check names the scenario’s own data:
```gherkin
Scenario: The courier is not called about a cancelled standard parcel
Given a seeds/cancel-standard.yaml db seed
And the mocked courier service with the following properties:
| url | http://localhost:8082 |
And a DELETE request to /api/parcels/PX-REG-1602
When the request is executed
Then the response status code is 204
And none of the mocked DELETE requests to path /v1/collections on courier have the query parameters:
| reference | PX-REG-1602 |
```
* **`none of the mocked ... requests to path ... have ...`** checks that no request to the path has every row of its table: `the query parameters`, `the payload properties` or `the form fields`. Put the scenario’s own data in the table, next to what must not be there.
* **`the mocked ... request to path ... named ... was not received`** checks that no request went to a path at all, whatever its query string. Use it for a path that is the scenario’s own, like one with its parcel’s reference.
Journals persist
WireMock keeps its request journal between scenarios, and between runs for as long as it keeps running, and scenarios run in parallel. Match on something unique to the scenario (a postcode or an id in the URL, a header, a property of the body) so one scenario never counts another scenario’s requests. A count such as `exactly 1 time` also counts the requests of earlier runs while the mock keeps running, as it does between runs with `axx up`. See [Isolate test data](/next/guides/isolate-test-data/).
## Mock gRPC dependencies
[Section titled “Mock gRPC dependencies”](#mock-grpc-dependencies)
The Axx WireMock image also carries [WireMock’s gRPC extension](https://wiremock.org/docs/grpc/), so a gRPC dependency is mocked like a REST one. Give the mock a `grpc` folder with the descriptor set of the dependency’s services (`protoc --include_imports --descriptor_set_out=rating.dsc rating.proto`), and stub each method as a POST to `/./`, with its request and answer as proto JSON:
infra/rating/mappings/rating-switzerland.json
```json
{
"name": "rating-switzerland",
"request": {
"method": "POST",
"urlPath": "/parcels.rating.v1.Rates/Quote",
"bodyPatterns": [{"matchesJsonPath": "$[?(@.country == 'CH')]"}]
},
"response": {"status": 200, "jsonBody": {"surchargeCents": 1250, "deliveryDays": 3}}
}
```
* **Another status than OK** is a response header: `"headers": {"grpc-status-name": "NOT_FOUND", "grpc-status-reason": "the carrier does not deliver to AQ"}`.
* **The mock steps check the calls** as they check REST requests: WireMock records each call as a POST to its method’s path, with its request as JSON.
```gherkin
Scenario: A parcel beyond the EU is priced by the partner carrier
Given the mocked rating service with the following properties:
| url | http://localhost:8084 |
And a POST request to /api/quotes
And a request payload using an application/json content example named 'Express abroad'
And the request payload properties are:
| weightGrams | 2350 |
| recipient.postcode | "8001" |
| recipient.country | CH |
When the request is executed
Then the response status code is 200
And the mocked POST request to path /parcels.rating.v1.Rates/Quote named rate was received by rating
And the payload properties for mocked request named rate on rating are:
| country | CH |
| weightGrams | 2350 |
| serviceLevel | EXPRESS |
```
Every scenario’s calls to a method share its path, so the payload check names data of the scenario’s own, here the weight. The gRPC extension runs when WireMock’s root has a `grpc` folder; the OpenAPI validation does not apply to gRPC calls. To call a gRPC service yourself, see [Call gRPC services](/next/guides/test-grpc/).
## Mock GraphQL services and subgraphs
[Section titled “Mock GraphQL services and subgraphs”](#mock-graphql-services-and-subgraphs)
The Axx WireMock image mocks a GraphQL service, or a federated subgraph, **field by field from its schema**. Set `GRAPHQL_SCHEMA_SOURCE` to its SDL, and the mock answers `POST /graphql` by running each operation against the schema: a field’s value comes from its parent’s value when that has it, and otherwise from a stub. Whatever fields a query selects, it gets an answer, which is what a gateway’s changing query plans need.
infra/compose.yaml (excerpt)
```yaml
shop-directory:
image: ghcr.io/nimbusxr/axx-wiremock: # a version with GraphQL mocks
environment:
GRAPHQL_SCHEMA_SOURCE: /home/wiremock/graphql/shops.graphql
volumes:
- './shop-directory:/home/wiremock'
```
Stubs are ordinary WireMock mappings, matched with WireMock’s matchers:
* **A field** is a POST to `/graphql//`, whose body is `{"arguments": {...}, "source": {...}}` (the field’s arguments, and its parent’s value).
* **An entity** of a subgraph (`_entities`) is a POST to `/graphql/_entities/`, whose body is its representation, such as `{"__typename": "Shop", "id": "alder-and-ash"}`.
* **The stub’s JSON body is the value.** A `graphql-error` header makes the field an error instead, with `graphql-error-code` as its `extensions.code`.
* **A field without a value is null,** and an entity without a stub too.
infra/shop-directory/mappings/shop-alder-and-ash.json
```json
{
"name": "shop-alder-and-ash",
"request": {
"method": "POST",
"urlPath": "/graphql/_entities/Shop",
"bodyPatterns": [{"equalToJson": {"id": "alder-and-ash"}, "ignoreExtraElements": true}]
},
"response": {"status": 200, "jsonBody": {"name": "Alder & Ash", "tier": "STANDARD"}}
}
```
A subgraph’s SDL makes the mock a subgraph: it answers `_service { sdl }` and `_entities`, so a gateway composes and plans with it. The journal records each operation the mock received, with its query and variables, so the mock steps check what was asked:
```gherkin
And the mocked POST request to path /graphql named shop-lookup was received by shop-directory
And the payload properties for mocked request named shop-lookup on shop-directory are:
| variables.representations[0].id | alder-and-ash |
```
A stub of a whole operation on `/graphql` (for example, matching `$.query`) wins over the field-by-field answers, for an outage or an answer the schema cannot give. The OpenAPI validation does not apply to GraphQL calls. To send operations to a GraphQL service yourself, see [Call GraphQL services](/next/guides/test-graphql/).
## Mock AI models
[Section titled “Mock AI models”](#mock-ai-models)
The Axx WireMock image mocks the models a service asks, in the format of the request: OpenAI’s API (and the servers that speak it), Anthropic’s, Gemini’s, Bedrock’s and Ollama’s, with their streams and their errors. A stub’s `model-request` matcher says which requests it answers (`about` a text of the conversation), and its `model-answer` transformer renders its answer, written the same way for every provider:
infra/models/mappings/address-note.json
```json
{
"request": {"customMatcher": {"name": "model-request", "parameters": {"about": "Birkenallee 3"}}},
"response": {
"transformers": ["model-answer"],
"jsonBody": {"json": {"name": "Mara Lindqvist", "street": "Birkenallee 3", "postcode": "01067", "city": "Dresden", "country": "DE"}}
}
}
```
The mock pack’s model steps check what the service asked the model: the texts, the tools and the schema. See [Test AI features](/next/guides/test-ai-features/).
## Mock MCP servers and A2A agents
[Section titled “Mock MCP servers and A2A agents”](#mock-mcp-servers-and-a2a-agents)
When your service calls another team’s MCP server, or asks another team’s A2A agent, the Axx WireMock image mocks them from their contract, as it mocks GraphQL subgraphs, with a mapping file per answer:
infra/compose.yaml (excerpt)
```yaml
partner-carrier:
image: ghcr.io/nimbusxr/axx-wiremock: # a version with MCP and A2A mocks
environment:
MCP_SERVER_SOURCE: /home/wiremock/mcp/partner-carrier.yaml
A2A_AGENT_CARD_SOURCE: /home/wiremock/a2a/agent-card.json
volumes:
- './partner-carrier:/home/wiremock'
```
* **An MCP server** is mocked from a description of what it lists, in MCP’s own field names: its `tools` (with their input and output schemas), `resources`, `resourceTemplates` and `prompts`. It answers at `/mcp` (`MCP_PATH`), in MCP 2026-07-28 and in 2025-11-25, 2025-06-18 and 2025-03-26.
* **A tool call** is answered by a stub of `POST /mcp/tools/`, whose body is the call’s arguments and whose JSON body is the tool’s result: `structuredContent`, `content` and `isError`. Arguments that break the tool’s input schema get a tool error, as from a real server, and a stub’s result is checked against the tool’s output schema.
* **Resources and prompts** are stubs of `POST /mcp/resources` and `POST /mcp/prompts/`.
* **An A2A agent** is mocked from its card: the mock serves the card and answers the JSON-RPC and HTTP+JSON interfaces it lists, streams included. A message is answered by a stub of `POST /a2a/messages`, whose body has the message’s `text`, and whose JSON body is the agent’s answer: a `reply`, or a task’s `state`, `message` and `artifacts`. A reply to a task continues it, since the mock remembers the tasks it answered.
infra/partner-carrier/mappings/shipment-status-px-ai-8105.json
```json
{
"name": "MCP: the partner carrier's shipment_status of PX-AI-8105, which cleared customs",
"request": {
"method": "POST",
"urlPath": "/mcp/tools/shipment_status",
"bodyPatterns": [{"equalToJson": {"reference": "PX-AI-8105"}}]
},
"response": {
"status": 200,
"jsonBody": {
"structuredContent": {
"reference": "PX-AI-8105",
"status": "CLEARED_CUSTOMS",
"location": "Basel",
"message": "PX-AI-8105 cleared customs in Basel",
"estimatedDelivery": "2026-10-01"
}
}
}
}
```
infra/partner-carrier/mappings/where-is-px-a2a-9206.json
```json
{
"name": "A2A: the partner carrier agent's answer about PX-A2A-9206, which cleared customs",
"request": {
"method": "POST",
"urlPath": "/a2a/messages",
"bodyPatterns": [{"matchesJsonPath": {"expression": "$.text", "contains": "PX-A2A-9206"}}]
},
"response": {
"status": 200,
"jsonBody": {
"state": "completed",
"message": "PX-A2A-9206 cleared customs in Basel and arrives on Thursday."
}
}
}
```
A mistake is loud: a description or card that is not one, or a stub for a tool the description does not list, stops WireMock at startup, and a call no stub answers gets an error that says so. The mock pack checks what your service called and asked:
```gherkin
Then the mocked partner-carrier mcp server's shipment_status tool was called with the following arguments:
| reference | PX-AI-8105 |
And the mocked partner-carrier a2a agent was sent a message containing 'PX-A2A-9206' 1 time
```
All settings are in the [extension’s README](https://github.com/nimbusxr/axx/tree/main/extensions/wiremock-openapi). To test your own MCP server or A2A agent, see [Test MCP servers](/next/guides/test-mcp/) and [Test A2A agents](/next/guides/test-a2a/).
## Check the dependency’s contract
[Section titled “Check the dependency’s contract”](#check-the-dependencys-contract)
A mock that answers something the real API never would makes a test pass for the wrong reason, and a service that calls its dependency wrongly only finds out in production. The `ghcr.io/nimbusxr/axx-wiremock` image is WireMock with Axx’s OpenAPI validation extension. It checks every call to the mock against the dependency’s OpenAPI document: your service’s request, and the stub’s response.
infra/compose.yaml (excerpt)
```yaml
services:
address-service:
image: ghcr.io/nimbusxr/axx-wiremock:0.1
ports: ["8081:8080"]
environment:
OPENAPI_SPEC_SOURCE: /var/openapi/address-service.yaml
OPENAPI_VALIDATION_MODE: report
volumes:
- ./wiremock:/home/wiremock
- ./openapi:/var/openapi:ro
```
`OPENAPI_SPEC_SOURCE` is the dependency’s document (a path inside the container or a URL). A stub can name another one with `"metadata": {"openApiSpecSource": "..."}`, or opt out with `"openApiValidation": false`. In `report` mode your service gets the stub’s answer unchanged, as it would from the real dependency, and Axx reports what broke the contract:
* A mock step that checks the call fails, and names the rule:
```console
✗ And the mocked GET request to /v1/postcodes/DE/44444 named zone-lookup was received by addresses
The GET /v1/postcodes/DE/44444 request named zone-lookup broke the contract of the mocked addresses service (/var/openapi/address-service.yaml):
- validation.response.body.schema.additionalProperties (response): property 'surcharge' is not defined in the schema and the schema does not allow additional properties
```
`(response)` means the stub broke the contract; `(request)` means your service did.
* A call that breaks the contract and that no scenario checks fails the run, after the scenarios:
```console
Run failures:
✗ Calls to the mocked addresses service (http://localhost:8081) broke its contract, and no scenario checked them: (mock)
GET /v1/postcodes/DE/55555 (/var/openapi/address-service.yaml)
- validation.response.body.schema.additionalProperties (response): property 'surcharge' is not defined in the schema and the schema does not allow additional properties
```
Axx only reads WireMock’s request journal, so scenarios running in parallel cannot affect each other’s checks. It reads the journals of the mocks the run’s scenarios register: register the mocked service in the `Background` of every feature whose scenarios make the calls, so Axx knows to look.
### Relax a check
[Section titled “Relax a check”](#relax-a-check)
Every rule is an `ERROR` until you relax it. Relaxing is how you tell Axx a deviation is known: `WARN` logs the finding on the checking step instead of failing it, `INFO` and `IGNORE` drop it. The rules have the same keys as your own service’s contract (`validation.request.body.schema.required`, `validation.response.status.unknown`, …), and a key also covers the keys below it. The settings are separate: [`the OpenAPI validation levels are:`](/next/guides/validate-openapi/) never relaxes a dependency’s contract.
Relax where the deviation lives:
| Where | Relaxes | Use it for |
| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `"openApiValidationLevels": {"validation.response.body": "WARN"}` in a stub’s metadata | the calls that stub answers, in every scenario and in the end-of-run check | a stub whose answer is off-contract wherever it is used |
| `OPENAPI_VALIDATION_LEVELS` on the container, e.g. `validation.response.body.schema.additionalProperties=WARN` | every call to that mock | a known gap between the dependency’s document and its behavior |
| the scenario step below | the calls that scenario’s mock steps check | a scenario that exercises an off-contract call on purpose, next to the behavior it checks |
In the example, the address service starts sending a field its document does not list yet, and quoting must keep working. The relaxation sits in that scenario, which checks the call:
```gherkin
Scenario: Quotes keep working when the address service sends a field it has not documented
Given the OpenAPI validation levels for the mocked addresses service are:
| validation.response.body.schema.additionalProperties | WARN |
And a POST request to /api/quotes
And a request payload using an application/json content example named 'Domestic parcel'
And the request payload property recipient.postcode is '"80995"'
When the request is executed
Then the response status code is 200
And the response payload property zone is 'DE-8'
And the mocked GET request to /v1/postcodes/DE/80995 named zone-lookup was received by addresses
```
The scenario step only reaches calls the scenario checks with a mock step: the end-of-run check cannot tell which scenario made a call. When a scenario relaxes a mocked service but checks none of its calls, Axx warns at the end of that scenario, and if one of its calls breaks the rule anyway, the run report names the scenario that meant to relax it.
The more specific place wins. Without the extension’s `report` mode (`fail`, its default), a call that breaks the contract also gets an HTTP 500 listing the findings; Axx reports it the same way. All settings, the admin endpoints and how the image is versioned are in the [extension’s README](https://github.com/nimbusxr/axx/tree/main/extensions/wiremock-openapi).
Keep mock response bodies schema-valid as the provider’s API evolves with [fixture factories](/next/guides/fixture-factories/): the `json` family can use an OpenAPI component as its schema, which is how the example generates `postcode-remote.json` from `infra/wiremock/__files/postcodes.factory.yaml`.
# Organize a suite by feature
> Give each feature a folder of its own and keep in it everything only that feature uses, its seeds, payloads, messages, screenshots and fixture factories, so one folder holds the whole feature.
A feature is what people read, change, run and delete as one. Give each feature a folder named after it, and keep in that folder the files only that feature uses: the seeds it starts from, the payloads it sends, the messages it publishes, the files it uploads or expects, its screenshots, and the fixture factories that generate any of them. Someone working on the feature opens one folder and has all of it.
```plaintext
acceptance/
axx.yaml
features/
manifest-import/
manifest-import.feature one file, the same on every platform
seeds/manifest-kestrel.yaml
seeds/manifests/xml/ a fixture factory, with the seeds it generates
courier-app/
courier-app-android.feature one file for each platform, when they differ
courier-app-ios.feature
seeds/ what both platforms start from
routes/grimmaische-strasse.gpx
shop-portal/
shop-portal.feature
screenshots/quote.chromium-linux.png
kafka/ what several features share
schemas/
infra/ the services' own setup: compose, WireMock mappings
```
That is the [parcels example](https://github.com/nimbusxr/axx/tree/main/examples/parcels/acceptance), laid out this way.
## One file, or one for each platform
[Section titled “One file, or one for each platform”](#one-file-or-one-for-each-platform)
A feature that is the same on every platform is one file named after the feature, `manifest-import.feature`, whichever platforms run it: a browser, a desktop or a phone is chosen by its tags, a profile or the run.
When the platforms differ, with screens of their own and steps that differ, each platform gets its own file in the feature’s folder, named after the feature and the platform: `courier-app-android.feature` and `courier-app-ios.feature`. Both read the folder’s files. A feature on one platform only says so the same way: `incidents-android.feature`. File names stay unique across the suite, so a search for `courier-app-ios` finds one file, not one of several `ios.feature`.
## How steps find a feature’s files
[Section titled “How steps find a feature’s files”](#how-steps-find-a-features-files)
A step that takes a file looks for it next to its feature file first, then in the `resources` directories of `axx.yaml`, then in the directory of `axx.yaml`. So the feature names its files from its own folder, and its paths stay short:
features/manifest-import/manifest-import.feature
```gherkin
Given a seeds/manifest-kestrel.yaml db seed
```
A file several features use goes in a folder of its own outside `features/` (`kafka/`, `schemas/`), where every feature finds it. Moving a file there is a decision you can see in a review, not an accident of where it was first written.
Screenshots follow the same rule: a check keeps its screenshot in a `screenshots` folder next to the feature file, unless `axx.yaml` sets a folder (`packs.web-screenshots.folder` and the other packs’ `screenshots.folder`). A screenshot kept in the project’s `screenshots` folder before is still found there.
## What stays outside the features
[Section titled “What stays outside the features”](#what-stays-outside-the-features)
What the services under test read is their setup, not a feature’s data: the WireMock mappings and the bodies WireMock serves, compose files, database migrations and init scripts. They live with the services (in the parcels example, `infra/`), even when one feature is the only one that needs a mapping. The feature’s own files are the ones its steps name.
## Why
[Section titled “Why”](#why)
* **One folder holds the feature.** Its scenarios and everything they read are side by side: nothing to hunt for across `seeds/`, `requests/` and `screenshots/`.
* **Changes stay in one place.** A change to a feature touches its folder, and a review shows the scenarios with the data they read.
* **Data stays with its scenarios.** A seed in a feature’s folder is that feature’s: another feature does not pick it up by accident, which keeps [scenarios isolated](/next/explanations/scenario-isolation/). What features share sits apart, on purpose.
* **Deleting a feature deletes its data.** No seeds are left behind that nothing reads, and no file has to be checked for other users first.
* **Short paths and local names.** `seeds/wen.yaml` only has to be unique in its folder, not across the suite.
* **Agents read one folder.** An agent adding a scenario or fixing one finds the feature and its files together, and the next feature’s folder shows it the pattern to follow.
* **A folder runs, moves and has an owner.** `axx run features/manifest-import` runs the feature; the folder moves to another suite whole; a `CODEOWNERS` line can give it to a team.
# Run in parallel
> Control how many scenarios run at once, keep scenarios that cannot share infrastructure on their own with run.exclusive, and randomize order to flush out hidden dependencies.
Axx runs scenarios in parallel by default, one per CPU. Parallelism is what keeps a suite of hundreds of black-box scenarios fast, and it only works if scenarios do not step on each other.
## Workers
[Section titled “Workers”](#workers)
axx.yaml
```yaml
run:
workers: auto # default: the number of CPUs; or a number
```
```sh
axx run --workers 4
axx run -w 1 # one at a time, for debugging an ordering problem
```
Each scenario runs start to finish on one worker. Steps inside a scenario always run in order. Workers share one machine; to share a run between machines, CI’s runners, give each a shard of it ([`--shard`](/next/guides/run-in-ci/#share-a-run-between-runners)).
## Scenarios that must run alone
[Section titled “Scenarios that must run alone”](#scenarios-that-must-run-alone)
Some scenarios change shared state in a way no other scenario can tolerate: they install database triggers, change a feature flag for the whole service, or restart a dependency. Tag them and list the tag in `run.exclusive`:
axx.yaml
```yaml
run:
exclusive: ["@isolated"]
```
```gherkin
@isolated
Feature: Database failures
Background:
Given a parcels-db database with the following properties:
| url | postgres://localhost:5432/parcels |
| user | parcels |
| password | parcels |
Scenario: A brief database failure does not fail the registration
Given a before insert trigger on the parcels.parcels table will raise a 40001 exception 1 time where:
| reference | PX-DBF-3001 |
```
Axx runs every other scenario in parallel first, then the exclusive ones one at a time. Tags are inherited, so tagging the feature covers every scenario in it.
## Order
[Section titled “Order”](#order)
```sh
axx run --order random # a new random order each run
axx run --order random:4242 # repeat a specific order
```
`run.order` in `axx.yaml` sets the default (`defined` unless you change it). A suite that passes in `defined` order but fails in a random one has scenarios that depend on each other.
## Timeouts
[Section titled “Timeouts”](#timeouts)
axx.yaml
```yaml
run:
timeouts:
step: 60s # default 10m
scenario: 5m # default: none
hook: 2m # default 2m
```
Durations are strings such as `90s` or `5m`, or a number of seconds. A step that runs over is cancelled and reported with `error.kind: timeout`. For behavior that takes time, use a polling step (`within 10s ...`) instead of a longer timeout.
## When parallel runs fail
[Section titled “When parallel runs fail”](#when-parallel-runs-fail)
A scenario that passes alone (`axx run features/x.feature:12`) but fails in a full run almost always shares data with another scenario. In order of preference:
1. Give it unique data, and enforce that with [`axx lint`](/next/guides/isolate-test-data/).
2. Assert on its own data only (filter selections by its ids, name mock request patterns by its URLs).
3. If it truly cannot share, tag it for `run.exclusive`.
Lowering `--workers` hides the problem instead of fixing it.
# Reports
> Choose console output, write JUnit, HTML, Cucumber JSON, Cucumber Messages and agent reports, and get machine-readable results with --json.
## Console output
[Section titled “Console output”](#console-output)
| Format | What you see | When |
| ---------- | ---------------------------------------------------------- | ----------------------------------------- |
| `pretty` | every scenario and step, tables, logs, failures, a summary | the default |
| `progress` | one character per scenario, then failures and a summary | long suites |
| `compact` | failures only, and one summary line | automatic for coding agents (`--compact`) |
```console
$ axx run --format progress features/register-parcels.feature
...F....
Feature: Register parcels
Scenario: A reference can only be registered once # features/register-parcels.feature:69
...
Failed scenarios:
x A reference can only be registered once # features/register-parcels.feature:69
rerun: axx run features/register-parcels.feature:69
8 scenarios (1 failed, 7 passed)
96 steps (1 failed, 1 skipped, 94 passed)
Finished in 0.6s
```
Problems that belong to no single scenario are listed under `Run failures:` after the failed scenarios, and fail the run. The one Axx reports today is a call to a [mocked dependency](/next/guides/mock-dependencies/#check-the-dependencys-contract) that broke its contract when no scenario checked the call. JUnit reports them as an `axx run` test suite, the agent report as `runErrors`, and Cucumber Messages in `TestRunFinished`.
`--compact` is turned on automatically when an agent is detected (`CLAUDECODE`, `CODEX_SANDBOX`, `GEMINI_CLI`, `CURSOR_AGENT`, `AGENT` or `AI_AGENT` is set) and stdout is not a terminal. `--no-color` or `NO_COLOR` turns colors off.
## Report files
[Section titled “Report files”](#report-files)
Add `--format NAME:FILE` once per report:
```sh
axx run \
--format pretty \
--format junit:build/axx/junit.xml \
--format html:build/axx/report.html
```
| Name | Output | Use it for |
| --------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `junit` | JUnit XML, one test case per scenario | CI test tabs (GitHub, GitLab, Jenkins) |
| `html` | a single self-contained HTML report | humans, as a CI artifact |
| `cucumber-json` | the classic Cucumber JSON format | tools that read Cucumber reports |
| `messages` | [Cucumber Messages](https://github.com/cucumber/messages) as NDJSON | the Cucumber ecosystem, custom tooling |
| `agent` | the compact JSON report below | agents, scripts |
| `teamcity` | TeamCity service messages: a live tree of features, scenarios and steps | IDE test runners (the axx IntelliJ plugin and VS Code extension run it) |
The same list can be the default in `axx.yaml`:
axx.yaml
```yaml
run:
reporters:
- pretty
- junit: build/axx/junit.xml
- html: build/axx/report.html
```
An unknown name fails with [`AXX-E0600`](/next/references/error-codes/#axx-e0600).
## Machine-readable results
[Section titled “Machine-readable results”](#machine-readable-results)
`axx run --json` prints one [JSON envelope](/next/references/json-output/) whose `data` is the run report. `--format agent:FILE` writes the same report to a file while the console shows another format. Only failures are listed, so the report stays small however large the suite is. [JSON output](/next/references/json-output/#the-run-report) describes every field.
The exit code says what kind of result it was without parsing any output; see [exit codes](/next/references/error-codes/#exit-codes).
# Run commands
> Run commands in scenarios, such as a command-line tool you ship or an operator's admin command, and check their exit code, what they print and what they print as errors.
Some of what a system does is done from the command line: an admin command an operations team runs where the service runs, a command-line tool you ship to customers, a migration or an export. The `cli` pack runs them in scenarios and checks how they end and what they print.
```gherkin
Scenario: The desk cannot cancel a parcel the courier has picked up
Given a seeds/admin-picked-up.yaml db seed
When the admin command is run with 'cancel PX-ADM-6102'
Then the admin command's exit code is 3
And the admin command's error output contains 'PX-ADM-6102 is IN_TRANSIT: it can no longer be cancelled'
And the admin command's output is empty
```
Add the pack to the project with `axx pack add cli` ([Choose packs](/next/guides/use-packs/)).
## Register the command
[Section titled “Register the command”](#register-the-command)
Register the command under a name, usually in the `Background`:
```gherkin
Background:
Given the admin command with the following properties:
| command | docker compose exec -T app parcels admin |
| dir | ../infra |
| timeout | 30s |
```
| Property | What it is |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `command` | The program and its first arguments (required). They are split like a service’s `command` in `axx.yaml`: double quotes group words, and there is no shell, so no pipes, redirects or `$VARIABLES`. |
| `dir` | The folder it runs in, relative to the directory of `axx.yaml`. By default, an empty folder of the scenario’s own. |
| `env.` | An environment variable, on top of those axx runs with. |
| `timeout` | How long it may run: `1m` unless it says otherwise. |
A program named by a relative path, such as `bin/parcels`, is found from the directory of `axx.yaml`. A bare name, such as `psql`, is looked up on the `PATH`. Values can use `${env:..}` and `${sys:..}`, and `${env:..}` values are masked in logs, attachments and failures.
A command that belongs to a service can run where the service runs: in its container, through `docker compose exec -T` (`-T` because no terminal is attached). The parcels example keeps the command in a property, so a run of the service on the host can replace it with `-D`:
axx.yaml
```yaml
properties:
parcels.admin: docker compose exec -T app parcels admin
```
```gherkin
Given the admin command with the following properties:
| command | ${sys:parcels.admin} |
| dir | ../infra |
```
## Run it
[Section titled “Run it”](#run-it)
Run the command with more arguments, which are split the same way, and, if it reads them, lines of input (its stdin):
```gherkin
When the admin command is run with 'label PX-ADM-6101'
```
```gherkin
When the admin command is run with 'cancel -' and the input:
"""
PX-ADM-6103
PX-ADM-6104
"""
```
A command that ends with a failing exit code does not fail the step that runs it: that is for the checks to say. Only a command that cannot start, or runs past its timeout, fails the step, and it shows what the command printed. A command that runs past its timeout is stopped with every process it started. A command that starts a process elsewhere, as `docker compose exec` does in a container, stops, but the process it started may run on.
## Check what it did
[Section titled “Check what it did”](#check-what-it-did)
The checks look at the command’s last run in the scenario:
```gherkin
Then the admin command's exit code is 0
And the admin command's output is:
"""
cancelled PX-ADM-6103
cancelled PX-ADM-6104
"""
```
| Check | What it compares |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `exit code is {int}` | The exit code. |
| `output contains {string}` | Text anywhere in what it printed. Runs of spaces and line breaks count as one space; a failure shows the nearest line. |
| `output has a line matching {string}` | A line that matches the regular expression (Java syntax). |
| `output is:` | The whole of what it printed, with `\r\n` read as `\n` and one final line break left out. |
| `output is empty` | Nothing printed at all. |
| `output has the following properties:` | JSON it printed: a path and its value, like the other JSON property steps. |
| `output is identical to the {filepath} file` | Byte for byte, a file of the project. |
Each check but the last two also reads the error output (stderr): `the admin command's error output contains '...'`.
A command’s JSON output is checked by its properties:
```gherkin
Then the admin command's output has the following properties:
| shop | pebble-and-pine |
| parcels[0].reference | PX-ADM-6105 |
| parcels[1].serviceLevel | EXPRESS |
```
And output a printer or another system reads, such as a shipping label, is compared with a file:
```gherkin
Then the admin command's output is identical to the labels/PX-ADM-6101.zpl file
```
An exit code of 0 says the command succeeded, not what it did. Check what it did too: what it printed, or a row it changed. `axx lint` points out a scenario whose only check is a success.
## Keep scenarios apart
[Section titled “Keep scenarios apart”](#keep-scenarios-apart)
Scenarios run in parallel, so a command must not write over another scenario’s files. By default a command runs in an empty folder of the scenario’s own: the folder is removed when the scenario passes and kept when it fails, for you to look at, and the scenario’s log says where it is. A command that works on shared data, like the admin command on the parcels database, gets data unique to the scenario, like every other step ([Isolate test data](/next/guides/isolate-test-data/)).
See the [cli pack’s reference](/next/references/packs/cli/) for every step.
# Run tests from an agent's sandbox
> Let an agent whose sandbox keeps it from Docker run acceptance tests on clean services. You start axx host on your machine; each run the agent starts gets its services from it, and runs only what you approved.
Some agent harnesses run the agent in a sandbox that may not use Docker, because the Docker socket would let it out of the sandbox. Its `axx run` then cannot start your services, and you end up running the tests yourself and pasting the output back. `axx host` starts the services on your machine for the agent’s runs, without giving the agent that way out.
## How it works
[Section titled “How it works”](#how-it-works)
* You start `axx host` in the project, on your machine, outside the sandbox. Your editor can start it for you ([below](#start-it-from-your-editor)). It listens on a loopback port and writes `.axx/host.json`.
* The agent runs `axx run` as it always does. Axx finds `.axx/host.json` in the project folder you share with the sandbox, and asks the host for the services the run needs.
* The host cleans up anything an earlier run left behind, then starts the services as `axx.yaml` says: `docker compose ... up --build`, so the agent’s changes are built. It waits until they are ready and sends their output back to the run’s `.axx/logs/services.log`.
* The scenarios run in the sandbox, under its rules: every request a step sends, every browser it opens.
* When the run ends, the host stops the services and runs their cleanup. It does the same when the run’s connection ends, so a run that is killed leaves nothing behind.
* Runs take turns. While one has the services, the next waits and says which run it waits for.
```console
$ axx host
axx host: serving /Users/you/shop on 127.0.0.1:53123 (Ctrl-C stops it)
approved: axx.yaml's services: api, db; compose.yaml
waiting for runs
run 1: axx run --compact features/orders
run 1: starting api, db
run 1: ready in 38.2s
run 1: 12 scenarios: 11 passed, 1 failed; stopped and cleaned up in 6.0s
```
In the sandbox, the run looks as it always does:
```console
$ axx run --compact features/orders
axx: asking the axx host (127.0.0.1:53123) for api, db
axx: the axx host started api, db (logs: .axx/logs/services.log)
```
Ctrl-C stops the host. A run that has the services at that moment loses them, and the host stops them and cleans up before it exits. The [command reference](/next/references/cli/axx-host/) lists its flags.
## What you approve
[Section titled “What you approve”](#what-you-approve)
The host never runs a command that a run sends. A run names services, and the host runs what the definition you approved says. The definition is:
* `axx.yaml`’s services: their commands, cleanups, readiness checks and environment;
* each compose project they run, as `docker compose config` resolves it, with its includes, its `.env` and its variables;
* the project files their commands run, such as `./scripts/start.sh` or the `server.js` of `node server.js`.
Starting the host approves the definition as it is. If the agent changes it, the next run waits for you:
```console
run 5: axx run --compact
run 5 (axx run --compact): the services' definition changed since you approved it
--- approved
+++ proposed
@@ -23,6 +23,11 @@
target: 8080
published: "18080"
protocol: tcp
+ volumes:
+ - type: bind
+ source: /var/run/docker.sock
+ target: /var/run/docker.sock
+ bind: {}
networks:
default:
warning: services.api: compose service api mounts the Docker socket (/var/run/docker.sock): it controls Docker on this machine
approve? [y/N]
```
The warnings name what in the definition reaches past a container onto your machine:
* privileged containers, added capabilities and devices;
* your machine’s network, process or other namespaces;
* mounts of files outside the project, and builds from outside it;
* files from outside the project read into a container’s environment or secrets;
* the Docker socket;
* services that run on your machine outside any container. Code the agent changes then runs on your machine.
How you answer decides what happens next:
* **`y`**: the run goes on, and the approval is kept. Runs with that definition don’t ask again.
* **`n`**, or Enter: the run fails with [`AXX-E0501`](/next/references/error-codes/#axx-e0501), which tells the agent you refused the change. The host refuses that definition again, without asking you, until it restarts, so an agent that runs again doesn’t ask you again. A newer change is asked about.
* **No answer within a minute**: the run fails with [`AXX-E0507`](/next/references/error-codes/#axx-e0507), and the question stays open. Answer it when you can: the agent’s next run has your answer. If the agent changes the definition again before you answer, the host asks about the newer one instead.
Lines about other runs are printed above the question, and the question is asked again below them. Anything typed before the question appears is dropped, so a stray `y` approves nothing. A host that doesn’t run in a terminal, such as one started in the background, can’t ask you: it says so as it starts, and refuses every change.
Changes to the app’s own code need no approval: compose builds it and runs it in containers. A Dockerfile’s steps run in build containers too, and what a build may use of your machine (its context, privileges, network) is set in the compose file, which the definition covers.
Approvals are kept in your own configuration directory, outside the project, where the agent cannot write them. Starting the host again doesn’t approve anything new: if the definition changed while no host ran, the host asks you about it as it starts.
## Check the sandbox first
[Section titled “Check the sandbox first”](#check-the-sandbox-first)
The host depends on one thing: the sandbox reaching your machine’s loopback addresses. That’s how the tests reach the services, and how Axx reaches the host. Check it once. Start a server in your terminal:
```sh
python3 -m http.server 8765
```
Then ask the agent to run `curl -sI http://127.0.0.1:8765/`. If it gets `HTTP/1.0 200 OK`, the sandbox reaches your machine. Databases and brokers don’t speak HTTP, so check one of their ports too: start the services in your terminal (`docker compose up -d`) and ask the agent to run `nc -z -w 2 127.0.0.1 5432 && echo open`, with your service’s port.
If the checks fail, the agent probably runs in a container, a virtual machine or a remote machine, which has loopback addresses of its own. `axx host` doesn’t serve those.
The project folder the agent edits must be the one on your disk, not a copy: the host builds what the agent changed there.
## Start it from your editor
[Section titled “Start it from your editor”](#start-it-from-your-editor)
The [VS Code extension and the IntelliJ plugin](/next/guides/set-up-your-editor/#the-axx-host) can start the host when you open the project, and stop it when you close it:
* An approval is a notification with **Show Diff**, **Approve** and **Refuse**, and the diff opens in the editor’s diff view. It works as the terminal’s question does: it stays open until you answer. Only a click on that notification approves. Nothing else in the editor can, including an agent’s editor tools.
* The agent’s runs show in the test results as they run, scenario by scenario.
* The status bar shows what the host is doing: idle, a run’s services starting or running, or waiting for your approval.
## Everyone’s runs share the host
[Section titled “Everyone’s runs share the host”](#everyones-runs-share-the-host)
* Your own runs go through the host while it runs, whether you start them in a terminal or in your editor. All runs use the same ports and the same compose project, so they take turns.
* `axx up` asks the host for the services and keeps them until `axx down`. Runs in between reuse them, as they do without a host.
* With the [MCP server](/next/guides/set-up-agents/), `steps_try` gets services of its own from the host when none are up, and `scenarios_run` ends them first, so that every run starts clean. `env` works through `axx up` and `axx down`.
* `axx doctor` says when a host serves the project, and leaves the checks of the services’ commands and of Docker to the machine the host runs on.
* If the host stops during a run, the run loses its services ([`AXX-E0502`](/next/references/error-codes/#axx-e0502)). A host that was killed leaves `.axx/host.json` behind. Runs then warn that it doesn’t answer, and start the services themselves.
The host’s errors are [`AXX-E0500` to `AXX-E0506`](/next/references/error-codes/#axx-host).
## What your security team should know
[Section titled “What your security team should know”](#what-your-security-team-should-know)
* The agent can reach one more loopback port. What it can send there is a run’s request: names of services from `axx.yaml`, and the configuration file, profile and settings it runs with. The host reads the configuration from the project itself, and runs only a definition you approved.
* `.axx/host.json` holds a token, readable only by your user. The agent, which runs as you, reads it; other users’ processes can’t.
* Approvals come only from you: from the host’s terminal, or from the editor that started the host. Never through the port.
* The app’s code, which the agent writes, runs in containers on your machine, with what the approved compose file gives those containers. Containers can reach the network unless the compose file keeps them from it, for example with an `internal: true` network.
# Run in CI
> Run Axx in GitHub Actions with setup-axx, in GitLab CI with the install script, or anywhere with the container image, and publish JUnit and HTML reports.
In CI, Axx does the same thing it does on a laptop: start the services, wait until they are ready, run the scenarios, stop everything. The only extra work is installing Axx and keeping the reports.
## GitHub Actions
[Section titled “GitHub Actions”](#github-actions)
`axx init` writes this workflow to `.github/workflows/acceptance.yml`:
.github/workflows/acceptance.yml
```yaml
name: acceptance
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
acceptance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: nimbusxr/setup-axx@v0
- run: axx run --format junit:build/axx/junit.xml --format html:build/axx/report.html
- uses: actions/upload-artifact@v7
if: always()
with:
name: axx-report
path: build/axx/
```
GitHub-hosted Ubuntu runners include Docker and Compose, so services that start with `docker compose up` work as they do locally. `nimbusxr/setup-axx` installs the latest release (pre-releases included) and adds it to `PATH`. Its `version` input installs another: a version such as `0.1.0`, or `nightly`. `@v0` is the action’s own version: its tag `v0` follows the action’s 0.x releases while Axx is in beta.
## GitLab CI
[Section titled “GitLab CI”](#gitlab-ci)
Install Axx with the install script. When the services start with Docker Compose, run the job with Docker-in-Docker and point the tests at the `docker` host through a profile:
.gitlab-ci.yml
```yaml
acceptance:
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- apk add --no-cache curl
- curl -fsSL https://axx.nimbusxr.us/install.sh | sh
script:
- axx run --profile ci --format junit:build/axx/junit.xml --format html:build/axx/report.html
artifacts:
when: always
paths: [build/axx/]
reports:
junit: build/axx/junit.xml
```
axx.yaml
```yaml
profiles:
ci:
properties:
local.host: docker # containers publish their ports on the dind host
```
This works when your features and `axx.yaml` use `${sys:local.host}` instead of a hard-coded `localhost`. The job runs as root, so the install script puts `axx` in `/usr/local/bin`, which is on `PATH`.
## Any other CI
[Section titled “Any other CI”](#any-other-ci)
Use the install script, or the container image for commands that do not start services:
```sh
docker run --rm -v axx-cache:/home/nonroot -v "$PWD:/work" -w /work ghcr.io/nimbusxr/axx validate
```
## Keep Axx’s cache
[Section titled “Keep Axx’s cache”](#keep-axxs-cache)
The first command in a project prepares Axx with the packs in `axx-packs.yaml` ([Choose packs](/next/guides/use-packs/)). On a fresh runner, that happens on every run. Keep the prepared builds between runs to skip it. In GitHub Actions, add this step before `axx run`:
```yaml
- uses: actions/cache@v6
with:
path: ~/.cache/axx/builds
key: axx-${{ runner.os }}-${{ hashFiles('**/axx-packs.yaml', '**/axx-packs.lock') }}
restore-keys: axx-${{ runner.os }}-
```
GitLab caches only paths inside the project, so move Axx’s cache there:
.gitlab-ci.yml
```yaml
acceptance:
variables:
XDG_CACHE_HOME: "$CI_PROJECT_DIR/.cache"
cache:
key:
files: [axx-packs.yaml]
paths: [.cache/axx/builds]
```
A stale cache is harmless: when the packs or Axx’s version change, Axx prepares again.
## Catch problems early
[Section titled “Catch problems early”](#catch-problems-early)
The exit code says what kind of failure it was, without parsing output: `1` is a failed scenario, `3` an undefined step, `4` a service that did not start ([exit codes](/next/references/error-codes/#exit-codes)). Two cheap guards catch the last two before any service starts:
```sh
axx validate # exit 3 on undefined steps, in seconds
axx doctor # exit 4 if a service's command is missing
```
Use `--fail-fast` to stop scheduling new scenarios after the first failure when you prefer a quick red over a full report.
## Reports
[Section titled “Reports”](#reports)
The workflows above write JUnit and HTML reports with `--format`. [Reports](/next/guides/reports/) lists every format, and `run.reporters` in `axx.yaml` can make them the default. `--rerun-file build/axx/rerun.txt` also records the failed scenarios for a follow-up run ([Rerun what failed](/next/guides/tags-and-filtering/#rerun-what-failed)).
## Share a run between runners
[Section titled “Share a run between runners”](#share-a-run-between-runners)
A suite whose scenarios take long on one runner (a device’s or a browser’s, which run a scenario at a time) can run on several at once: `--shard 2/3` runs the second third of the scenarios the run selects, every third in their files’ and lines’ order, from the second. The shards of a run, `1/3` to `3/3`, run each scenario once between them, the same on every machine:
.github/workflows/acceptance.yml
```yaml
jobs:
acceptance:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v5
- uses: nimbusxr/setup-axx@v0
- run: axx run --shard ${{ matrix.shard }}/3 --format junit:build/axx/junit.xml
```
Each runner starts the services for its own shard. A shard that selects nothing passes.
## Run scenarios in parallel safely
[Section titled “Run scenarios in parallel safely”](#run-scenarios-in-parallel-safely)
CI runners have fewer CPUs than laptops, and `run.workers` defaults to the number of CPUs. If scenarios that pass locally fail in CI, check that they use unique data ([Isolate test data](/next/guides/isolate-test-data/)) before lowering `--workers`.
# Seed and query SQL
> Load datasets into PostgreSQL (and MySQL, SQLite or SQL Server), assert on rows and JSON columns, wait for asynchronous writes, and inject database faults with triggers.
The SQL steps put a database in a known state before a scenario and check what your service wrote afterwards. PostgreSQL is first-class; MySQL, SQLite and SQL Server are supported.
## Register the database
[Section titled “Register the database”](#register-the-database)
```gherkin
Background:
Given a parcels-db database with the following properties:
| url | postgres://localhost:5432/parcels |
| user | parcels |
| password | parcels |
```
Native URLs (`postgres://`, `mysql://`, `sqlserver://`, `sqlite:`) and JDBC-style URLs (`jdbc:postgresql://`, `jdbc:mysql://`, `jdbc:mariadb://`, `jdbc:sqlserver://`, `jdbc:sqlite:`) are accepted. Name a table with its schema, like `parcels.parcels`, or set `currentSchema` in a PostgreSQL URL. The drivers are pure Go and come with the `sql` pack. JSONB containment and triggers need PostgreSQL, and row locks a database other than SQLite; seeds, selections and row counts work on every database.
## Seed
[Section titled “Seed”](#seed)
```gherkin
Given a seeds/manifest-kestrel.yaml db seed
Given a seeds/dispatching.yaml db seed on parcels-db
```
A seed is a dataset keyed by table. YAML is the usual format:
seeds/manifest-kestrel.yaml
```yaml
parcels.manifest_lines:
- id: "ML-KES-0412-1"
manifest_id: "M-KESTREL-0412"
reference: "PX-KES-1001"
sender: "kestrel-books"
weight_grams: 850
service_level: "STANDARD"
recipient: '{"name": "Emmy Noether", "street": "Bunsenstrasse 3", "city": "Goettingen", "postcode": "37073", "country": "DE"}'
- id: "ML-KES-0412-2"
manifest_id: "M-KESTREL-0412"
reference: "PX-KES-1002"
sender: "kestrel-books"
weight_grams: 2300
service_level: "EXPRESS"
recipient: '{"name": "Lise Meitner", "city": "Hamburg", "postcode": "20095", "country": "DE"}'
```
Flat XML (`.xml`), JSON (`.json`), Excel (`.xlsx`, one sheet per table) and CSV dataset directories (one `
.csv` per table, in the order `table-ordering.txt` lists when there is one) work the same way. The `[null]` and `[UNIX_TIMESTAMP]` placeholders are supported, and so are times counted from now, such as `[DAY,NOW]`, `[DAY,PLUS,1]` or `[HOUR,MINUS,2]` (in `DAY`, `HOUR`, `MIN` or `SEC`). A time counted from now is the machine’s local time. PostgreSQL also gets its offset, so a `timestamptz` column holds that very moment whatever time zone the machine is in, and a `timestamp` column holds the local wall-clock time. Values are escaped, so text containing `'` is safe.
Scenarios share the database and run in parallel, so give every seeded row an id that belongs to one scenario. [Isolate test data](/next/guides/isolate-test-data/) shows how to enforce that with `axx lint`.
A seed inserts its rows in file order, in one transaction. It never updates or deletes existing rows, so a row whose key already exists fails the step and nothing from that file is written.
## Select rows and assert
[Section titled “Select rows and assert”](#select-rows-and-assert)
A selection is a query with equality conditions. Later steps check its size and contents:
```gherkin
Then a selection of rows is retrieved from the parcels.manifest_lines table where:
| manifest_id | M-KESTREL-0412 |
And the selection has 2 rows
And the selection has more than 1 row
And the selection has fewer than 5 rows
```
A scenario can hold several selections. They are numbered in the order they are retrieved, and later steps address them by ordinal (`the 2nd selection`); in `a 2nd selection of rows is retrieved` the ordinal is only a label. Leaving the ordinal out means the first:
```gherkin
Then a selection of rows is retrieved from the parcels.manifest_lines table where:
| manifest_id | M-KESTREL-0412 |
And a 2nd selection of rows is retrieved from the parcels.manifest_lines table where:
| id | ML-KES-0412-1 |
And the 2nd selection has 1 row
```
### JSON columns
[Section titled “JSON columns”](#json-columns)
Assert on properties inside a JSON or JSONB column of a selected row:
```gherkin
Then a selection of rows is retrieved from the parcels.parcels table where:
| reference | PX-REG-1001 |
And the 1st row details property for the selection json properties are:
| source | api |
| zone | DE-1 |
And the 1st row details property for the selection json properties match:
| zone | ^DE-.*$ |
```
Or select by JSONB containment (PostgreSQL):
```gherkin
Then a selection of rows is retrieved from the parcels.parcels table where the details jsonb column contains:
| manifestId | M-KESTREL-0412 |
And the selection has 2 rows
```
Selections are numbered in the order a scenario retrieves them, and a step without an ordinal checks the first. After a second retrieval, name the one a step checks: `the 2nd selection has 1 row`. `axx validate` warns about a step that checks the first while a later selection goes unchecked.
In `json properties are:` and `json properties match:`, paths are JSONPaths such as `zone` or `items[0].sku`, and values are compared as text ([the rules](/next/references/packs/sql/#sqljsonare)). The containment step takes dotted names (`recipient.city`) and compares each value as a JSON string, so it never matches a number or a boolean.
## Wait for asynchronous writes
[Section titled “Wait for asynchronous writes”](#wait-for-asynchronous-writes)
When the service writes in the background (importing a manifest, say), poll instead of sleeping:
```gherkin
Then within 10s a selection of at least 2 rows is retrieved from the parcels.manifest_lines table where:
| manifest_id | M-KESTREL-0412 |
| status | IMPORTED |
And the selection has 2 rows
```
The step queries again every 500 ms until the selection has at least that many rows, and moves on as soon as the rows appear; it fails when the time runs out with fewer. `the selection has 2 rows` then checks that no more than those came back.
## Inject faults with triggers
[Section titled “Inject faults with triggers”](#inject-faults-with-triggers)
To test how your service handles database errors, install a trigger that raises an SQL state for matching inserts:
```gherkin
@isolated
Scenario: A brief database failure does not fail the registration
Given a before insert trigger on the parcels.parcels table will raise a 40001 exception 1 time where:
| reference | PX-DBF-3001 |
And a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload property reference is 'PX-DBF-3001'
When the request is executed
Then the response status code is 201
And the before insert trigger on the parcels.parcels table was raised 1 time
```
The trigger raises a serialization failure (`40001`) once; the service retries and the second insert succeeds. Without `1 time` the trigger raises on every matching insert. Another variant inserts the row and then raises (`... will insert and raise a 23505 exception where:`).
Triggers change a shared table, so tag those scenarios with a tag in `run.exclusive` (for example `@isolated`) to run them alone after the parallel phase. See [Run in parallel](/next/guides/parallel-runs/).
## Row locks
[Section titled “Row locks”](#row-locks)
```gherkin
Scenario: A parcel cannot be changed while the depot dispatches it
Given a seeds/dispatching.yaml db seed
And the rows in the parcels.parcels table are locked where:
| reference | PX-DSP-2001 |
And a PATCH request to /api/parcels/PX-DSP-2001
And a request payload using an application/json content example named 'Heavier'
When the request is executed
Then the response status code is 409
Scenario: A parcel can be changed again once the depot lets go of it
Given a seeds/dispatched.yaml db seed
And the rows in the parcels.parcels table are locked where:
| reference | PX-DSP-2002 |
And a PATCH request to /api/parcels/PX-DSP-2002
And a request payload using an application/json content example named 'Heavier'
When the row locks are released
And the request is executed
Then the response status code is 200
```
Lock rows to test timeouts and contention in your service. The lock holds until `the row locks are released` or the end of the scenario.
## Named databases
[Section titled “Named databases”](#named-databases)
Every step has an `on ` form for scenarios with more than one database, for example `a selection of rows is retrieved from the parcels.parcels table on parcels-db where:` and `the selection on parcels-db has 1 row`.
# Seed and query MongoDB
> Register a MongoDB database, insert documents from JSON seed files, and assert on the documents your service wrote.
The MongoDB steps put documents in place before your service reads them, and check the documents it writes.
## Register the database
[Section titled “Register the database”](#register-the-database)
```gherkin
Background:
Given a tracking-db mongo database with the following properties:
| url | mongodb://localhost:27017/parcels?authSource=admin |
| user | parcels |
| password | parcels |
```
All three properties are required and support `${env:...}` and `${sys:...}`. The URL must include the database name; `authSource` defaults to it. The first MongoDB database registered in a scenario is the default.
## Write a seed file
[Section titled “Write a seed file”](#write-a-seed-file)
A seed is a JSON object that maps collection names to arrays of documents. Extended JSON (`{"$oid": "..."}`, `{"$date": "..."}`) is supported:
seeds/scans-in-transit.json
```json
{
"scans": [
{
"scanId": "SC-3001-2",
"parcelRef": "PX-TRK-3001",
"status": "IN_TRANSIT",
"location": "Hamburg hub",
"scannedAt": {"$date": "2026-05-05T06:40:00Z"}
},
{
"scanId": "SC-3001-1",
"parcelRef": "PX-TRK-3001",
"status": "PICKED_UP",
"location": "Berlin depot",
"scannedAt": {"$date": "2026-05-04T16:10:00Z"}
}
]
}
```
## Seed and use it
[Section titled “Seed and use it”](#seed-and-use-it)
```gherkin
Scenario: Tracking shows the latest scan, however the scans arrive
Given a seeds/scans-in-transit.json mongo db seed
And within 10s a selection of at least 1 document is retrieved from the tracking collection where:
| _id | PX-TRK-3001 |
| scanCount | 2 |
When a GET request to /api/parcels/PX-TRK-3001/tracking
And the request is executed
Then the response status code is 200
And the response payload properties are:
| status | IN_TRANSIT |
| lastLocation | Hamburg hub |
| scanCount | 2 |
```
The service builds its tracking summaries from the scans in the background, so the scenario [waits for the summary](#wait-for-asynchronous-writes) before it calls the API.
To seed a specific database when the scenario registers more than one:
```gherkin
Given a seeds/scans-in-transit.json MongoDB seed for tracking-db
```
`a seeds/scans-in-transit.json mongo db seed for tracking-db` is the same step with the other spelling.
The file path resolves against the `resources` directories in `axx.yaml`. See the [mongo pack reference](/next/references/packs/mongo/).
## Query and assert
[Section titled “Query and assert”](#query-and-assert)
A document selection finds documents with equality conditions. Dotted field paths reach into nested documents, and [the mongo pack reference](/next/references/packs/mongo/#mongofind) says how values are read:
```gherkin
Then a selection of documents is retrieved from the scans collection where:
| parcelRef | PX-TRK-3001 |
| status | PICKED_UP |
And the selection has 1 document
And the 1st document for the selection properties are:
| scanId | SC-3001-1 |
| location | Berlin depot |
| scannedAt | 2026-05-04T16:10:00.000Z |
```
Properties are JSONPaths (`location`, `history[0].status`), compared as text; `null` means null and `undefined` means the field is absent ([the rules](/next/references/packs/mongo/#mongodocare)). `the 1st document for the selection properties match:` takes Java regular expressions instead.
Selections are numbered in the order a scenario retrieves them, and a step without an ordinal checks the first. After a second retrieval (the tracking view before and after a scan, say), name the one a step checks: `the 1st document for the 2nd selection properties are:`. `axx validate` warns about a step that checks the first while a later selection goes unchecked.
Like the SQL selections, they are numbered in the order they are retrieved (`the 2nd selection has 3 documents`), and `has more than` / `has fewer than` compare counts.
### Wait for asynchronous writes
[Section titled “Wait for asynchronous writes”](#wait-for-asynchronous-writes)
```gherkin
Then within 10s a selection of at least 1 document is retrieved from the tracking collection where:
| _id | PX-TRK-3001 |
| scanCount | 2 |
```
The step polls every 500 ms until enough documents match, and fails when the time runs out with fewer. The documents it found are the selection, for later steps to check (`the selection has 1 document`, or its properties). Values that parse as JSON are typed, so `2` matches the number 2.
Every query step has an `on ` form for scenarios with more than one MongoDB database.
## Keep documents apart
[Section titled “Keep documents apart”](#keep-documents-apart)
Seeded documents stay in the database after the scenario, and scenarios run in parallel. Give each scenario’s documents their own keys (`PX-TRK-3001` above) and query by them. [Scenario isolation](/next/explanations/scenario-isolation/) explains the approach.
# Send REST requests
> Payloads from files, the redirects the REST pack follows and the ones it leaves to the scenario, cookies, and the headers custom steps give a request.
A scenario registers a REST service, adds requests to it, executes them and checks their responses ([rest pack](/next/references/packs/rest/)). This page is about what happens in between: the payload a request sends, the redirects it follows, the cookies it sends, and the headers a custom step gives it. To sign a request as a webhook is signed, or authorize it with a token, see [Test webhooks and tokens](/next/guides/webhooks-and-tokens/).
The REST pack tests APIs. To test a web app’s pages, forms or sign-in, use the web-core pack: a real browser keeps cookies, follows redirects and submits forms, as the app’s users do ([Test web apps](/next/guides/test-web-apps/)).
## Payloads from files
[Section titled “Payloads from files”](#payloads-from-files)
A payload starts from an example of the service’s OpenAPI specification, from an empty template the payload property steps fill, or from a file of the project, sent as it is:
```gherkin
Scenario Outline: A shop's order system registers the parcels it exports
Given a POST request to /api/parcels
And a request payload using an application/json requests/.json resource
When the request is executed
Then the response status code is 201
And the response header Location is '/api/parcels/'
Examples:
| order | reference |
| order-7731 | PX-REG-1301 |
| order-7732 | PX-REG-1302 |
```
* **The file is found like the other files steps name:** relative to the `resources` directories, or to `axx.yaml`’s directory.
* **A JSON payload can still change.** The payload property steps edit it, like a payload from an example. A property inside an object the payload lacks creates the object: `recipient.name` and `recipient.postcode` fill an empty template’s `recipient` ([ADR 0010](https://github.com/nimbusxr/axx/blob/main/docs/adr/0010-payload-properties-create-missing-objects.md)).
* **A form payload file is a JSON object.** With `application/x-www-form-urlencoded`, its properties are sent form-encoded.
* **No OpenAPI specification is needed,** which suits a callback or a webhook that the service has no contract for.
A [fixture factory](/next/guides/fixture-factories/) keeps such files in one place and checks them against a schema. The parcels example generates the two files above with `requests/registrations.factory.yaml`, from the `ParcelRequest` schema of the service’s OpenAPI contract.
## Several requests in one scenario
[Section titled “Several requests in one scenario”](#several-requests-in-one-scenario)
Requests are numbered in the order a scenario adds them. The first is the default, which steps without an ordinal use; every other request is added, executed and checked with its ordinal:
```gherkin
Scenario: A registered parcel can be looked up by its reference
Given a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload property reference is 'PX-REG-1401'
And a 2nd ordered GET request to /api/parcels/PX-REG-1401
When the request is executed
And the 2nd ordered request is executed
Then the response status code is 201
And the 2nd ordered response status code is 200
And the response payload property reference is 'PX-REG-1401' for 2nd ordered response
```
* **Add a request before using it:** a step that uses the 2nd request fails unless a 2nd was added before it. `axx validate` says so, with the step that adds it.
* **A second `a GET request to …` adds the 1st again**, and fails: write it as `a 2nd ordered GET request to …`. `axx validate` shows the line to write.
* **A check names its response:** `the 2nd ordered response status code is 200`, or `… for 2nd ordered response` at the end of the other response steps. A check without one reads the 1st response.
## Find an item in a list
[Section titled “Find an item in a list”](#find-an-item-in-a-list)
A property can pick items out of a list with a JSONPath filter: `parcels[?(@.reference=='PX-REG-1401')].status` is the status of that parcel. The value a check compares is the list of what matched, so one match reads `'["delivered"]'`.
```gherkin
Then the response payload property parcels[?(@.reference=='PX-REG-1401')].status is '["delivered"]'
And the response payload property parcels[?(@.service=~/Next\sday\sair/)].reference is '["PX-REG-1401"]'
```
* **A path is one word**, without spaces. To match a text with spaces, use `=~` and a regular expression, where `\s` stands for the space.
* **`=~` matches the whole text**, as Jayway’s JSONPath does: `/Next\sday/` doesn’t match “Next day air”, while `/Next\sday.*/` does.
## Redirects
[Section titled “Redirects”](#redirects)
| Request | Redirects it follows | The response the steps check |
| ----------------------------------- | ----------------------------------------- | -------------------------------- |
| `GET`, `HEAD` | 301, 302, 303, 307 and 308 | the one the redirects lead to |
| `POST`, `PUT`, `PATCH`, `DELETE`, … | `303 See Other`, with a `GET` | after a 303, the one it leads to |
| `POST`, `PUT`, `PATCH`, `DELETE`, … | none of the others: 301, 302, 307 and 308 | the redirect itself |
A redirect the pack follows is not kept: its status, its `Location` header and the cookies it sets are not the response’s, and the response steps cannot check them. The scenario’s log shows the request as the step sent it and the status at the end, like `GET http://localhost:8080/api/parcels/PX-REG-1010 -> 200 OK (12ms, 412 bytes)`.
The `GET` that follows a 303 has no body and no `Content-Type`. It has the request’s other headers, `Authorization` and `Cookie` only when the redirect stays on the same host or goes to a subdomain of it.
### Redirects left to the scenario
[Section titled “Redirects left to the scenario”](#redirects-left-to-the-scenario)
A `POST`, `PUT`, `PATCH` or `DELETE` answered with 301, 302, 307 or 308 has the redirect as its response. Check its status and its `Location` header, and send the request it leads to as the next ordered request. Say a service moved its API from `/api/v1` and answers the old paths with `308 Permanent Redirect`:
```gherkin
Scenario: A cancellation sent to the old API cancels the parcel through its redirect
Given a 1st ordered DELETE request to /api/v1/parcels/PX-REG-1010
And a 2nd ordered DELETE request to /api/parcels/PX-REG-1010
When the 1st ordered request is executed
And the 2nd ordered request is executed
Then the 1st ordered response status code is 308
And the response header Location is '/api/parcels/PX-REG-1010' for 1st ordered response
And the 2nd ordered response status code is 204
```
The scenario sends both requests, then checks both responses: the redirect, where it leads, and the cancellation there.
## Cookies
[Section titled “Cookies”](#cookies)
The REST pack keeps no cookies. A `Set-Cookie` response header is never sent back: not with the scenario’s later requests, and not to where a redirect leads. A request carries the cookies its scenario gives it, in its `Cookie` header (`the request header Cookie is '...'`), and nothing else, so scenarios running side by side never share cookies.
## Headers from custom steps
[Section titled “Headers from custom steps”](#headers-from-custom-steps)
A custom step changes a request the pack’s steps built, before it is executed, through the request’s headers: `Header()` returns the headers it is sent with, and `Set`, `Add` and `Del` change them. `SetHeader(name, value)` does what the request header steps do. [Write custom steps](/next/guides/write-custom-steps/#change-a-rest-request) has an example.
# Set up agents
> Give coding agents the Axx skills, the Axx MCP server and the AGENTS.md section. axx init sets them up for the agents a repository uses, axx mcp install connects one more, and axx doctor checks them.
Three pieces give a coding agent everything it needs: the skills, the MCP server and the `AGENTS.md` section. They reinforce each other, and `axx init` sets up all three for the agents the repository already uses. [Test with an agent](/next/tutorials/with-an-agent/) shows them in use.
## What axx init sets up
[Section titled “What axx init sets up”](#what-axx-init-sets-up)
`axx init` works in the repository only and lists every file it writes. It never writes to your home directory.
| When the repository has | `axx init` writes |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| anything | the `AGENTS.md` section and the skills in `.agents/skills/` |
| `.claude/`, `CLAUDE.md` or `.mcp.json` (Claude Code) | `.mcp.json`, links the skills into `.claude/skills/`, and imports `AGENTS.md` into `CLAUDE.md` where Claude Code reads that instead ([AGENTS.md](#agentsmd)) |
| `.codex/` (Codex) | `.codex/config.toml` |
| `.cursor/` (Cursor) | `.cursor/mcp.json` |
| `.vscode/` (VS Code) | `.vscode/mcp.json` |
| `.gemini/` or `GEMINI.md` (Gemini CLI) | `.gemini/settings.json` |
In a repository with a `CLAUDE.md` and a `.cursor/` directory:
```console
create axx.yaml
create axx-packs.yaml
create features/smoke/smoke.feature
create .github/workflows/acceptance.yml
create AGENTS.md
update CLAUDE.md (imports AGENTS.md for Claude Code)
create .gitignore
create .gitattributes
create .agents/skills (5 skills)
create .claude/skills (linked for Claude Code)
create .mcp.json (the axx MCP server for Claude Code)
create .cursor/mcp.json (the axx MCP server for Cursor)
Codex keeps its MCP servers in ~/.codex/config.toml; `axx mcp install --agent codex --scope user` adds axx there.
next: put your service's command, port and health check in axx.yaml and the smoke feature, then `axx doctor` and `axx run`
```
Axx adds its server to the files that exist: other servers and settings stay where they are, and a file that has the axx server already is left alone, so running `axx init` again changes nothing. A file Axx cannot read back exactly, such as JSON with comments, is left alone too: its line says so, and `axx mcp install --agent ` shows what to add by hand. Codex usually reads its servers from your home directory, which `axx init` never writes, so it prints the command that does. `axx init --no-agents` skips the skills and the MCP servers.
Commit what `axx init` wrote, so every contributor and every CI agent gets it.
## Skills
[Section titled “Skills”](#skills)
```sh
axx skills install # this repository
axx skills install --scope user # your home directory, for every repository
axx skills list
```
`install` writes the skills to `.agents/skills/` (read by Codex, Cursor, Gemini CLI and Copilot) and, when the repository uses Claude Code (a `.claude/` directory, `CLAUDE.md` or `.mcp.json`), links them into `.claude/skills/` for it. `--claude` links them anyway, and `--no-claude` never; with `--scope user`, they are linked when your home directory has `.claude/`. A `.claude/` that holds nothing but these links does not count as using Claude Code, so `axx doctor` does not ask a Codex repository for a Claude Code MCP server. `axx init` installs them the same way, and marks them as generated in `.gitattributes`, so GitHub collapses their diffs in pull requests. Commit them so every contributor and every CI agent gets them.
| Skill | Teaches |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `axx-acceptance-tests` | the write, validate, run loop; rules that keep tests reliable; the step index |
| `axx-test-data` | seeds, payloads and mock bodies in [fixture factories](/next/guides/fixture-factories/): `axx fixtures`, identities, adoption, `axx lint`, CI |
| `axx-setup` | `axx init`, services and readiness, CI, agent integration |
| `axx-custom-steps` | custom steps as Go packs |
| `axx-debugging` | reading failures, exit codes and logs |
The skills carry no pages of steps: they send agents to `axx steps`, which lists your project’s steps, your custom packs’ included, a line each, and to `axx steps show ` for one step’s documentation. Agents read pages a chunk at a time, about ten times the tokens. Rerun `axx skills install` after upgrading Axx; files you edited are kept unless you pass `--force`, and files the skills no longer have are removed. The same skills are published at [`/.well-known/agent-skills/index.json`](/next/.well-known/agent-skills/index.json).
## MCP server
[Section titled “MCP server”](#mcp-server)
`axx mcp` serves Axx over the [Model Context Protocol](https://modelcontextprotocol.io) on stdio. Because it is the installed binary, its answers match your Axx version and your project’s steps.
| Tool | Does |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `steps_search` | without a query, the catalog: every step of the project, one line each; with one, the steps that fit it (id, expression, table columns and an example; 6 by default), a step it returned before as its id only |
| `step_explain` | with a line, how it matches (or the closest steps: first the line written out when it keeps an expression’s notation, or with its ordinal moved where the step has it); with an id or several, the steps’ documentation and examples |
| `feature_validate` | check feature files or feature text without running; warnings come from `axx lint`’s feature checks (a scenario that checks several behaviors in turn among them), and hints name scenarios whose checks prove little |
| `lint_run` | run `axx lint`: values such as seed ids that collide across files, with `file:line`, and the rules that found something |
| `scenarios_run` | run scenarios (paths, tags, names); returns failures with expected and actual, the warnings `feature_validate` and `lint_run` would give, and for a passing run their hints |
| `failure_context` | logs, attachments and the last request and response of one failure; without a run ID, of the latest run |
| `env` | `up`, `down` or `status` of the services: which are running, left over from a killed run, or not cleaned up (`axx env` on the command line) |
| `config_show` | the effective `axx.yaml`, with secrets redacted, and its packs (`axx config show`) |
| `scaffold` | starter contents for a feature or an `axx.yaml` |
| `steps_try` | try steps in a live scenario that stays open between calls, until `restart` |
Packs add tools of their own, which look at the scenario `steps_try` keeps open. The `web-core` pack’s:
| Tool | Does |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `web_page` | the page the session’s web app is on: its elements as steps name them, what a screen reader reads, script errors and a screenshot |
An agent tries the steps it is unsure of, looks at the page they led to, then writes them into the feature:
```json
{"steps": "Given the portal web app with the following properties:\n | url | http://localhost:8400/portal |\nWhen the \"/quote\" page is opened"}
```
The services must be running (`env` `up`). The session’s browsers close when the agent restarts it or the server stops.
It also serves the `axx.yaml` JSON Schema as a resource and a `write-acceptance-tests` prompt. Pass `--profile ci` (or any profile) to apply it to every tool call.
### Connect an agent
[Section titled “Connect an agent”](#connect-an-agent)
`axx mcp install --agent ` adds the server to one agent’s configuration: in the repository by default, or with `--scope user` in your home directory, which only this command writes, and only when you ask. Like `axx init`, it keeps what the file holds and leaves a file that has axx alone. `--dry-run` shows what it would write.
```sh
axx mcp install --agent cursor # .cursor/mcp.json
axx mcp install --agent codex --scope user # ~/.codex/config.toml
```
| `--agent` | `--scope project` (the default) | `--scope user` |
| ---------------------------------- | ------------------------------- | ----------------------------------------------------------------------- |
| `claude` (Claude Code) | `.mcp.json` | prints `claude mcp add --scope user axx -- axx mcp` |
| `codex` (Codex) | `.codex/config.toml` | `~/.codex/config.toml` (`$CODEX_HOME/config.toml` when set) |
| `cursor` (Cursor) | `.cursor/mcp.json` | `~/.cursor/mcp.json` |
| `vscode` (VS Code, GitHub Copilot) | `.vscode/mcp.json` | prints `code --add-mcp '{"name":"axx","command":"axx","args":["mcp"]}'` |
| `gemini` (Gemini CLI) | `.gemini/settings.json` | `~/.gemini/settings.json` |
Claude Code keeps your own servers with the rest of its state, and VS Code in its profile, so for them Axx prints the agent’s own command instead of editing those files. Codex loads a project’s `.codex/config.toml` only once you trust the project.
This is what each agent’s file gets.
#### Claude Code
[Section titled “Claude Code”](#claude-code)
.mcp.json
```json
{
"mcpServers": {
"axx": { "command": "axx", "args": ["mcp"] }
}
}
```
Claude Code asks before it starts a server from a project’s `.mcp.json`. Or install the skills and the server together as a plugin:
```text
/plugin marketplace add nimbusxr/axx
/plugin install axx@nimbusxr
```
#### Codex
[Section titled “Codex”](#codex)
.codex/config.toml or \~/.codex/config.toml
```toml
[mcp_servers.axx]
command = "axx"
args = ["mcp"]
```
#### Cursor
[Section titled “Cursor”](#cursor)
.cursor/mcp.json
```json
{
"mcpServers": {
"axx": { "command": "axx", "args": ["mcp"] }
}
}
```
#### VS Code (GitHub Copilot)
[Section titled “VS Code (GitHub Copilot)”](#vs-code-github-copilot)
.vscode/mcp.json
```json
{
"servers": {
"axx": { "type": "stdio", "command": "axx", "args": ["mcp"] }
}
}
```
#### Gemini CLI
[Section titled “Gemini CLI”](#gemini-cli)
.gemini/settings.json
```json
{
"mcpServers": {
"axx": { "command": "axx", "args": ["mcp"] }
}
}
```
## Check the setup
[Section titled “Check the setup”](#check-the-setup)
`axx doctor` checks, for the agents the repository uses (and Codex, when your machine has it), that the skills are installed and that the agent finds the axx MCP server, in the repository’s file, your own, or (Claude Code) the plugin. What is missing is a warning with the command that fixes it:
```console
ok agent skills 5 skills
ok Claude Code skills 5 skills
ok Claude Code MCP axx server in .mcp.json
warn Cursor MCP no axx server in .cursor/mcp.json
→ `axx mcp install --agent cursor` adds it
```
## Agents in a sandbox without Docker
[Section titled “Agents in a sandbox without Docker”](#agents-in-a-sandbox-without-docker)
Some harnesses run the agent in a sandbox that may not use Docker, so its runs cannot start the services. Start `axx host` in the project on your own machine: the agent’s runs, `axx up` and MCP sessions get their services from it, clean for each run, and it starts only what you approved. [Run tests from an agent’s sandbox](/next/guides/run-from-a-sandbox/) shows how.
## AGENTS.md
[Section titled “AGENTS.md”](#agentsmd)
`axx init` adds a managed section to `AGENTS.md`, the file most agents read first. Running `axx init` again updates the section in place and leaves the rest of the file alone:
AGENTS.md
```markdown
## Acceptance tests (axx)
axx (github.com/nimbusxr/axx, "axxeptance") verifies running software against executable acceptance criteria (Gherkin), black-box.
- Writing tests: `axx steps` lists every step, a line each (read it once; `axx steps show ` gives one
step's documentation); write one scenario per acceptance criterion with those steps, never
invented ones, written out (a {name} replaced with a value, a(n) as "a" or "an", [[...]] kept
without the brackets or left out); `axx up` once, then `axx run --compact`, which also reports what validate and lint find;
`axx down` when done.
- Use the axx MCP tools when you have them: `steps_search` without a query is the step list, then
`scenarios_run` (it reports what `feature_validate` and `lint_run` find) and `failure_context`.
- If `.agents/skills` has the axx skills, they teach the details: axx-acceptance-tests, axx-test-data
(fixture factories), axx-debugging. `axx skills install` installs or updates them.
- Feature files are acceptance criteria a person can read, in plain language, with the steps as
written. No programming constructs in Gherkin.
- Every scenario uses unique data (ids, names, keys): scenarios run in parallel and data persists.
- Check what the service did (a response property, a row, an event) and why it refused, not only
status codes.
- `services:` in axx.yaml are what runs on its own, reached by an address; a program its user starts (a CLI, an
MCP server over stdio) is run by its step's `command`, as its user would, and is not a service.
- Steps come from the packs in `axx-packs.yaml` (`axx pack list`, `axx pack add `). Configuration is in
`axx.yaml` (`axx schema --outline` lists its keys); features in `features/`.
- Repeating payloads, seeds or mock bodies: fixture factories generate them (`axx fixtures`;
optional, strongly recommended where data repeats); `axx fixtures adopt` turns hand-written ones into one.
```
Claude Code reads `AGENTS.md` only where no `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` is in the folder or one above it; where one is, it reads those instead. So in a repository that uses Claude Code, `axx init` adds `@AGENTS.md`, Claude Code’s import, to the project’s `CLAUDE.md` (or to `.claude/CLAUDE.md`, or to a new `CLAUDE.md` when the one Claude Code reads is your own `CLAUDE.local.md` or a folder above), and Claude Code reads the section with it. A `CLAUDE.md` that imports `AGENTS.md` already, or links to it, is left alone.
## Docs for agents
[Section titled “Docs for agents”](#docs-for-agents)
* Every page on this site has a Markdown twin: append `.md` to the path (`/guides/set-up-agents.md`). Pages advertise it with ``.
* [`/llms.txt`](/next/llms.txt) indexes the site; [`/llms-full.txt`](/next/llms-full.txt) and [`/llms-small.txt`](/next/llms-small.txt) contain it in one file.
* The `axx.yaml` schema is at [`/schemas/v0/axx.schema.json`](/next/schemas/v0/axx.schema.json); `axx schema` prints the same thing offline.
# Set up your editor
> See Axx's steps in feature files as you write them - problems flagged as you type, step completion, docs on hover and highlighted parameters - in IntelliJ IDEA, VS Code or any editor with LSP support.
Axx’s steps are defined inside the `axx` binary, not in your project, so an editor cannot find them on its own. `axx lsp`, a language server built into the binary, tells the editor about them. The IntelliJ plugin and the VS Code extension start it for you. In feature files you get:
* undefined, ambiguous and misused steps, and Gherkin syntax errors, flagged as you type, with the closest real steps;
* completion of step text, with the step’s parameters as placeholders;
* the step’s documentation, and the values of its parameters, on hover;
* go to a step’s definition: the Go code that defines it when that code is on your machine (your custom packs, or axx built from source), otherwise the step’s entry in a reference page;
* the files that steps name: Ctrl/Cmd+click a seed, a payload, a schema, an OpenAPI document or a log’s `file://` url to open the file, get its path completed as you type, and a warning when it does not exist (with a pointer to `axx fixtures generate` for a fixture that is not generated yet). These are the steps’ `{filepath}` parameters and file properties, and the Examples cells that fill them in; the file is found the way the step finds it, in the `resources` directories, next to `axx.yaml` or at an absolute path. A log’s file may appear only during the run, so it is never flagged;
* highlighted parameter values and Scenario Outline ``.
The steps are the ones your project loads, custom packs included ([Choose packs](/next/guides/use-packs/)). The server finds the project’s `axx.yaml` itself, even when it is in a subdirectory of the folder you opened.
Pre-release
The IntelliJ plugin is not listed on the JetBrains Marketplace, and the VS Code extension is not on the Visual Studio Marketplace. Build them from the repository: `./gradlew buildPlugin` in `ide/intellij`, `npm ci && npm run package` in `ide/vscode`.
## IntelliJ IDEA
[Section titled “IntelliJ IDEA”](#intellij-idea)
Needs IntelliJ IDEA 2026.1.4 or later (or another JetBrains IDE of that version).
1. Install the **axx** plugin from the JetBrains Marketplace: *Settings | Plugins | Marketplace*, search for axx.
2. Install the **Gherkin** plugin too: it highlights Gherkin keywords, and the axx plugin needs it for the run icons in the gutter, for running a feature file or directory, and for breakpoints on steps. The Gherkin plugin, and Cucumber+ if you have it, look for step definitions in code and would mark every Axx step as undefined, so the axx plugin turns those inspections off in Axx projects.
3. Open a `.feature` file. The server appears in the **Language Services** widget in the status bar, where you can restart it.
If `axx` is not on your `PATH`, set **axx executable** in *Settings | Tools | axx*.
## VS Code
[Section titled “VS Code”](#vs-code)
1. Install the **axx** extension: *Extensions: Install from VSIX…* with the `.vsix` file `npm run package` writes.
2. Disable the official Cucumber extension in Axx workspaces. It looks for step definitions in code and marks every Axx step as undefined.
3. Open a `.feature` file. The extension highlights Gherkin itself and starts the server once the workspace is trusted.
If `axx` is not on your `PATH`, set `axx.path` to its location. The **axx** output channel shows the server’s log.
## Other editors
[Section titled “Other editors”](#other-editors)
Any editor with LSP support can run `axx lsp` for `*.feature` files. It talks over stdio and finds the project from its working directory. In Neovim 0.11 or later, which gives `.feature` files the `cucumber` file type:
init.lua
```lua
vim.lsp.config('axx', {
cmd = { 'axx', 'lsp' },
filetypes = { 'cucumber' },
root_markers = { 'axx.yaml', '.git' },
})
vim.lsp.enable('axx')
```
## Run and debug scenarios
[Section titled “Run and debug scenarios”](#run-and-debug-scenarios)
Both plugins run scenarios from the editor, with the results in the IDE’s test view: features, scenarios and steps, each one linked to its line, with expected and actual values for failed assertions.
* **IntelliJ IDEA:** click the run icon in the gutter of a Feature, Rule, Scenario or Scenario Outline line, or of an Examples row, or choose *Run* on a feature file or directory. *Rerun Failed Tests* is in the test view’s toolbar.
* **VS Code:** use the run buttons in the gutter or the Testing view.
### With profiles
[Section titled “With profiles”](#with-profiles)
A run from the editor can apply the project’s [profiles](/next/references/config/#finding-and-merging-files), in order, as `axx run --profile ios,watch` does:
* **IntelliJ IDEA:** the axx run configuration has a **Profiles** list with the project’s profiles: check the ones to apply, and move them up and down to set their order. To start every run from the gutter, the editor and the project view with them, check them in the axx configuration template (*Run | Edit Configurations | Edit configuration templates | axx*); those runs are named with them, like `features (ios)`. For one file or directory, *More Run/Debug | Modify Run Configuration…* in its context menu opens its configuration, with the list.
* **VS Code:** the gear next to *Run*, *Debug* and *Watch* in the Testing view picks the profiles all three apply, kept in the workspace’s `axx.profiles` setting. They apply in the order the setting lists them; a project without one of them runs without it.
*Debug* instead of *Run* stops at breakpoints in the Go code of the steps ([Stop in step code](/next/guides/debug-failures/#stop-in-step-code)). It needs a Go debugger: GoLand or IntelliJ IDEA with the Go plugin, or the Go extension in VS Code. Without one, *Debug* runs the scenarios without stopping in step code; breakpoints on steps still pause.
## Watch a run
[Section titled “Watch a run”](#watch-a-run)
Scenarios can run with what they do on your screen: their browsers in windows, an iOS simulator in Device Hub, an Android emulator in its window. They run one scenario at a time, slowed down so you can follow what happens ([Watch a run](/next/guides/watch-runs/)).
* **IntelliJ IDEA:** choose *Watch* next to *Run* and *Debug*, in the gutter menu of a Feature, Rule, Scenario or Examples row line, or in the context menu of a feature file or directory. To watch every run of an **axx** run configuration, check **Watch the browsers** in it.
* **VS Code:** run with the **Watch** profile: from the menu next to the run button in the Testing view, or with *Execute Using Profile* on a gutter button.
Watching runs `axx run --workers 1 --watch --slowdown 300ms`. Set the slowdown in milliseconds, `0` for none: **Slowdown when watching the browsers** in *Settings | Tools | axx*, or `axx.watch.slowdown` in VS Code.
To stop before a step, click in the gutter of its line to set a breakpoint, then *Debug*. The run pauses before that step, and Playwright’s Inspector opens next to the browser: resume the run, step through its actions, pick an element or record new ones. When debugging, a failed scenario also pauses there, at the step that failed. *Debug* adds `--debug-steps --workers 1 --set packs.web-core.pauseOnFailure=true` to the run, and `--pause-at` for each breakpoint, as in `axx run features/.feature --pause-at features/.feature:`. *Watch* ignores breakpoints.
While a scenario is paused, put the cursor on a step to highlight the element it names in the page; the status bar says what the step finds, or why it would fail. To try a step in the paused scenario, run it: *Run Step in Paused Scenario* in the editor’s context menu (Ctrl+Alt+Shift+R in IntelliJ IDEA), or the **Run in paused scenario** link above each step in VS Code. *Insert Recorded Steps*, in the editor’s context menu, inserts the steps you recorded in the Inspector below the cursor.
## Open traces and videos
[Section titled “Open traces and videos”](#open-traces-and-videos)
When a scenario keeps a Playwright trace (a failed one does, by default) or a video, the editor adds it to the scenario’s test:
* **IntelliJ IDEA:** the scenario’s output in the test view ends with an *Open trace* or *Play video* link, and the scenario’s context menu has **Open Trace** and **Play Video**.
* **VS Code:** right-click the scenario in the Testing view, or its gutter button, for **Open Trace** and **Play Video**. The scenario’s test output has links too.
A trace opens in an editor tab, in Playwright’s trace viewer. The viewer comes with the Playwright driver Axx downloads, and the editor serves it with the trace from your machine, on `127.0.0.1`: nothing but the viewer and the files runs named. So traces open after the run too, offline. Without the driver’s viewer, the editor shows the file in your file manager: drop it on [trace.playwright.dev](https://trace.playwright.dev). A video plays in an editor tab.
In IntelliJ IDEA without its built-in browser (JCEF), traces open in your default browser, and videos in your video player. In VS Code, **Open in Browser** at the top of each tab opens its page in your default browser.
## The axx host
[Section titled “The axx host”](#the-axx-host)
For an agent whose sandbox keeps it from Docker, [`axx host`](/next/guides/run-from-a-sandbox/) starts the project’s services on your machine. Both plugins can run it for you:
* **VS Code:** turn on `axx.host.start` in your user settings, and the host starts when a workspace folder whose `axx.yaml` declares services opens. A workspace’s own settings can’t turn it on, because an agent can write them. **axx: Start axx host** and **axx: Stop axx host** start and stop it by hand.
* **IntelliJ IDEA:** check **Start the axx host when an axx project opens** in *Settings | Tools | axx*. It’s an IDE setting, kept outside the project, so an agent can’t turn it on. *Tools | Start axx Host* and *Tools | Stop axx Host* start and stop it by hand.
The plugin stops the host when the project closes. Stopping it stops the services of a run in progress and cleans up.
While the host runs:
* A run whose services changed, or the host as it starts, asks you in a notification, which lists what in the change reaches past a container. It stays until you answer, as the terminal’s question does. **Show Diff** opens the approved and the proposed definition in the diff view. **Approve** lets runs start the services, and **Refuse** fails the run that waits. Only a click there answers: no command or action approves, so neither other plugins nor an agent’s editor tools can. If you closed the notification, click the host in the status bar, or use **Show axx host approval** (*Tools* menu in IntelliJ IDEA, Command Palette in VS Code), to see it again.
* The runs the host serves that the editor didn’t start, such as the agent’s, show in the test results as they run, named after the run (`axx host: run 4 (axx run --compact)`): a tab of the Run tool window in IntelliJ IDEA, which opens for them without taking the focus, and a test run of their own in VS Code’s Testing view.
* Your own runs from the editor go through the host too, and take their turn with the agent’s.
* The status bar shows what the host is doing: ready, a run’s services starting or running, waiting for your approval, or already running in a terminal. A host you started in a terminal serves the editor’s runs too, and asks there.
* The host’s log is in the **axx host** output channel in VS Code, and in IntelliJ IDEA’s log (*Help | Show Log in Finder/Explorer*).
## Projects and their packs
[Section titled “Projects and their packs”](#projects-and-their-packs)
Each Axx project in the editor (each `axx.yaml`) gets the steps of its own packs, so a repository with several projects works as one with a single project does. When a project’s `axx.yaml`, its `axx-packs.yaml` or the code of a pack of its own changes, the server prepares the project again and loads its steps: a step you add to your pack is known once you save it. Until the new steps are ready, the ones before them answer, and if they cannot load, the editor says why and keeps them.
After you upgrade Axx, restart the server: from the Language Services widget in IntelliJ, with **axx: Restart language server** in VS Code, or by restarting the editor.
# Stub embedded models
> Replace a model your app or service runs itself with a stub: a model of the real one's format and contract (ONNX, TFLite, Core ML, ExecuTorch, OpenVINO, MNN, ncnn, TensorFlow, Keras, TensorFlow.js, TorchScript, PyTorch Mobile, GGUF, safetensors, CTranslate2, whisper.cpp, Parakeet, LiteRT-LM, MNN-LLM, MLC (WebLLM), Tesseract, Piper, Foundation Models), a fraction of its size, whose answers are its mappings. The app's runtime loads it as it loads the real model. A model the platform provides (Vision, Speech, Foundation Models and Apple's server model, ML Kit, Gemini Nano, a browser's built-in AI: its Prompt API, Summarizer, Writer, Rewriter, Translator, LanguageDetector, Proofreader and speech recognition; the Windows AI APIs) gets a stub of its API, and a Windows or Linux desktop's voice and Linux's Tesseract a stub of their own format.
Some models your apps and services run themselves: an image classifier on a phone (LiteRT on Android, Core ML on iOS), a model a service loads with ONNX Runtime, a language model it runs with llama.cpp. These are embedded models. A real one fills a simulator’s or an emulator’s memory, takes seconds to load, and answers as it likes. It also will not, on demand, give the answer that sends a claim to a person.
A stub replaces the model. It is a model of the real one’s format, with the same inputs and outputs, and its answers are its mappings. The app’s runtime loads it as it loads the real model, and an input like one of the mappings’ gets that mapping’s answer. The app’s test build or test deployment puts the stub where the app gets its model, the way a compose file points a service at WireMock. Nothing in the app changes. A model the platform provides (Vision, Speech and Foundation Models on iOS and macOS; Android’s ML Kit, Gemini Nano and speech recognizer; a browser’s built-in AI; the Windows AI APIs) has no file of the app’s to replace: a stub of its API answers the app’s calls in its test runs instead. A Windows or Linux desktop’s voice, and Linux’s Tesseract, take a stub of their own format, a Piper voice or a Tesseract language ([Models the platform provides](#models-the-platform-provides)).
Models a service reaches over HTTP (OpenAI, Anthropic, Gemini, Bedrock, Ollama) are mocked with WireMock instead: see [Test AI features](/next/guides/test-ai-features/).
## Four steps, for every model
[Section titled “Four steps, for every model”](#four-steps-for-every-model)
Every model is stubbed the same way, whatever its format and wherever the app runs it:
1. **[Write its contract](#1-write-the-contract)**, once, from the real model: what the app relies on from it.
2. **[Map inputs to answers](#2-map-inputs-to-answers)**: the pictures (or other inputs) your scenarios give the app, and what the model answers each.
3. **[Build the stub](#3-build-the-stub)** with `axx models build`, and keep it with the app’s sources.
4. **[Put it where the app gets its model](#4-put-it-where-the-app-gets-its-model)**, in the test build or the test deployment.
Then the scenarios test what the app does with each answer, with the steps they already use. There are no steps for models: the app runs its model, and the scenarios see what it does.
List the `models-core` pack in the project’s `axx-packs.yaml`, and give each model a directory of the acceptance project:
```text
models/evidence-check/
contract.json what the app relies on from the model (step 1)
mappings/
damage-photos.json the inputs it answers, and its answers (step 2)
default.json the answer to every other input
```
## 1. Write the contract
[Section titled “1. Write the contract”](#1-write-the-contract)
The contract is what the app relies on from the model:
* its inputs and outputs (names, types, shapes);
* an image input’s layout;
* a classifier’s labels;
* how an integer output stands for scores (`quant`);
* the version of its format, so the app’s runtime loads the stub as it loads the model.
Write it from the real model, once, and keep it with the project. No test needs the real model after that.
```sh
axx models contract mobilenetv2-7.onnx --task classification --labels labels.txt \
-o models/evidence-check/contract.json
```
| Format | Models | What the contract takes from the model |
| -------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ONNX | `.onnx` | Its inputs and outputs, IR version, operator sets, operators and metadata, and the files beside it its weights are in (external data). A classifier’s labels come from `--labels`. A speech recognizer (Whisper’s encoder, `encoder_model*.onnx`, its decoder beside it) is a `speech-to-text` model’s: its input a sound’s log-mel spectrogram of the size its `preprocessor_config.json` gives, its decoder’s inputs and outputs, and the tokens its `generation_config.json` ends and suppresses, with its tokenizer in `model-files`. A language model’s decoder (`input_ids`, `attention_mask` and its cache, `past_key_values`, in; `logits` and `present` out, as Hugging Face’s exporters and ONNX Runtime GenAI write them) is a `text-generation` model’s: the token that ends its turn comes from its folder’s configs, and its tokenizer (`tokenizer.json`, beside it or in the folder above, as the model’s repository has it) goes in `model-files`. An image model’s image processor (`preprocessor_config.json`, beside it or in the folder above, as transformers.js and Optimum keep it) says how it fits a picture to its input, in RGB: stretched to its `size` of a height and a width (ViT’s, BiRefNet’s), or its shorter side resized to its `shortest_edge` and its middle cropped to its `crop_size` (ConvNeXT’s, CLIP’s: `crop` its share). |
| TFLite (LiteRT) | `.tflite` | Its first subgraph’s inputs and outputs (their quantization too), its signature, operators, metadata buffers and the files appended to it. A classifier’s labels come from its metadata, when it has them. |
| ExecuTorch | `.pte` | Its forward method’s inputs and outputs, by their places (`input0`, `output0`…: an app gives and gets them in order), its other methods’ (React Native ExecuTorch’s text recognizers have a method for each width of a strip of text), and the kernels and delegates it calls. Only the program is read: the head of the file, before its weights. A stub has every method, each of its own shapes. A language model’s program (forward of its tokens and their position, and methods that give its settings, `get_max_seq_len`, `get_eos_ids`…, as ExecuTorch’s LLM export writes it) is a `text-generation` model’s: its settings are the real program’s, the token that ends its turn is one of its `get_eos_ids`, and its tokenizer (`tokenizer.json`, beside it or in the two folders above, as the model’s repository has it) goes in `model-files`. A speech recognizer’s program (`encode` of a sound’s samples, `decode` of a token, its position and the encoder’s state, as React Native ExecuTorch exports Whisper) is a `speech-to-text` model’s: its input `encode`’s sound (16 kHz), and its tokenizer in `model-files`. |
| TensorFlow SavedModel | `saved_model.pb` | Its meta graph for serving (its tags) and its signature (`serving_default`, or its first): its inputs and outputs by their keys, which TF Serving and `signatures[...]` call it by, and the names of their tensors in its graph (`serving_default_inputs:0`, `StatefulPartitionedCall:0`), which a session of TensorFlow 1 runs it by; the operators of its graph and its functions, and the version of TensorFlow that saved it. Its variables are not read. |
| TensorFlow\.js | `model.json` (its shards beside it) | A graph model’s signature (as TensorFlow\.js’s converter writes it from version 2): its inputs and outputs by their keys, in their order (`predict` gives and gets them in it), and the operators of its graph. A graph model of no signature (of the converter before version 2, as tfjs-models’ older models are): its placeholders and the nodes no node takes, by their names, as TensorFlow\.js runs it; its outputs’ shapes come from `--output`. Its integers are int32s, as TensorFlow\.js has them. A layers model (Keras’s layers, `tf.loadLayersModel`) is not read. |
| Keras | `.keras`, `.h5` | A Functional or Sequential model’s config (a `.keras` file’s `config.json`, a `.h5` file’s `model_config`, of Keras 3 or Keras 2): its inputs (their names, types and shapes), its outputs by their layers’ names, and their shapes where its last layers say them (a `Dense`’s units, a `Reshape`‘s shape); the version of Keras that saved it, and its layers. Its weights are not read. Other outputs’ shapes, and a subclassed model’s inputs and outputs, come from `--input` and `--output`. |
| TorchScript | `.pt`, `.torchscript` | Its forward’s tensors (its parameters by their names, its outputs by their places: `output0`, `output1`…), its class, and the operators its code calls. TorchScript does not say its tensors’ types and shapes: they come from what the model’s folder has (an Ultralytics export’s config, inside it: its pictures’ size, its task and labels; Triton’s `config.pbtxt` beside or above it), or from `--input` and `--output`. |
| PyTorch Mobile | `.ptl` (a module’s `_save_for_lite_interpreter`: Android’s and iOS’s PyTorch Lite runtimes, `LiteModuleLoader`) | As TorchScript’s, from the code the file keeps beside its bytecode, and the operators its bytecode calls (`aten::conv2d`, `aten::add.Tensor`…), those a selective build of PyTorch Mobile made for the model alone has. |
| MNN | `.mnn` | Its inputs (their names, types and dimensions), its outputs’ names (those it names, or the tensors no op takes, as MNN’s loader has them), and the ops it uses. MNN does not say its outputs’ types and shapes: they come from `--output`. |
| ncnn | `.param` (its `.bin` beside it) | Its inputs (its `Input` layers’ blobs, of the dimensions they give: a picture’s of no batch, CHW, as ncnn’s `from_pixels` gives it), its outputs’ names (the blobs no layer takes), and the layers it uses. ncnn does not say its outputs’ shapes, nor (pnnx’s models) its inputs’: they come from `--input` and `--output`, of no batch, as ncnn’s blobs are. |
| OpenVINO | `.xml` (its `.bin` beside it) | Its inputs (its Parameters) and outputs (its Results, by their tensors’ names), its IR version and the operations it uses; a classifier’s labels, and whether the app gives a picture’s channels reversed (BGR), from the `config.json` beside it (Open Model Zoo’s). |
| Core ML | `.mlmodel`, `.mlpackage` | Its inputs (an image’s size and colors, or a multiarray), its outputs (a classifier’s label and probabilities, with its class labels, or multiarrays), its specification’s version and metadata. A detector’s pipeline (a network of candidates, then a non-maximum suppression: Create ML’s object detectors, YOLO’s Core ML exports, as Vision runs them) is a `detection` model’s of its suppression’s labels, its outputs the objects it keeps (`confidence`, `coordinates`); the contract carries its suppression and its description. A text encoder (its tokens’ ids, `input_ids`, as Hugging Face’s exporters and coremltools convert BERT’s and MiniLM’s) matches texts with its tokenizer: the `tokenizer.json` beside it or in the folder above, or `--vocab vocab.txt`. |
| safetensors (transformers) | `model.safetensors` (a folder of it) | A language model’s folder, as Hugging Face’s repositories have it: its vocabulary’s size and context length (`config.json`), the token that ends its turn (`generation_config.json`’s, or `config.json`‘s), and its tokenizer (`tokenizer.json`) in `model-files`. A text embedding model’s folder (an encoder’s `config.json`: BERT’s, MPNet’s…; or sentence-transformers’ `modules.json`) is an `embedding` model’s of its hidden size, or of its last Dense module’s features (EmbeddingGemma’s: 768 to 3072 to 768). A speech recognizer’s folder (Whisper’s: a `config.json` of `model_type` `whisper`, its `generation_config.json` and `preprocessor_config.json`) is a `speech-to-text` model’s: its sizes, the tokens its transcripts begin and end with and the ones it suppresses, its features’ size, and its tokenizer in `model-files`. |
| CTranslate2 | `model.bin` (a folder of it) | A Whisper’s (its spec’s, `WhisperSpec`: faster-whisper’s models): its sizes (its variables’), its settings (its scalars), the tokens its transcripts begin and end with (its tokenizer’s, `tokenizer.json` beside it, which goes in `model-files`) and those its `config.json` suppresses. |
| whisper.cpp | `ggml-*.bin` | A Whisper’s model file, as whisper.cpp’s converters write it: its sizes, the type of its weights, the tokens whisper.cpp begins and ends its transcripts with, and its mel filters and vocabulary (tiktoken’s, by their ranks), which go in `model-files`. |
| Parakeet (parakeet.cpp) | `.gguf` (Parakeet’s, as parakeet.cpp’s converters write NVIDIA’s CTC, TDT and hybrid TDT-CTC models) | Its features (their mels and sample rate) and its vocabulary’s size. Its task is `speech-to-text`, its input the sound (`"features": "parakeet"`): parakeet.cpp computes the features from the file’s own frontend, so a stub has a frontend of its own, whatever the real model’s. |
| OpenAI Whisper | `.pt` (OpenAI’s checkpoints: `tiny.en.pt`…) | A checkpoint’s dims, the tokens the `openai-whisper` package begins and ends its transcripts with and suppresses, and its vocabulary: the package’s `whisper/assets/multilingual.tiktoken` (or `gpt2.tiktoken`, of an English-only model), which goes in `model-files`. Copy it beside the checkpoint first. A TorchScript module, also a `.pt` file, is told apart by its content. |
| Tesseract | `.traineddata` | The contract is written by hand: a grayscale image input of a fixed size, and the CTC output’s labels (the characters it reads, space, and the blank `""`). Its task is `text-recognition`. The stub reads an image of the contract’s size as one line, as it is (page segmentation mode 13, which its config sets, and an app’s `--psm 13` keeps): an app gives it crops of one line, as an app reads a field of a form or a label. |
| Piper | `.onnx` (with `.onnx.json` beside it) | The voice’s inputs and output, sample rate and `phoneme_id_map`, and its sidecar config. Its task is `text-to-speech`; a mapping’s `text` is found anywhere in what the app speaks, in any case (Piper reads a text case-folded), and its `audio` is a WAV, resampled to the voice’s rate. The output waveform is fixed in the contract (use `--output` when the real model’s is open: Piper’s voices say as long as they speak). Piper’s runtimes (its Python package, its command-line program) leave a pause after each sentence (0.2 s), and scale a waveform to its loudest sample. |
| GGUF (llama.cpp) | `.gguf` | A language model’s: its task (`text-generation`), its context length, the token that ends its turn, and its metadata (tokenizer, chat template), which goes in `model-files`. An embedding model’s (one that pools its tokens’ outputs: BERT’s, bge-m3, nomic-embed, EmbeddingGemma, Qwen3-Embedding) is an `embedding` model’s: its embedding’s size and its pooling (mean, the first token’s, the last’s). A reranker’s (a classifier’s, `cls`, of RANK pooling: bge-reranker, jina-reranker, Qwen3-Reranker) is a `classification` model’s of one label, `relevant`, its output `score` the logit of the mapping’s `score`: llama.cpp’s server answers a BERT reranker’s logit and node-llama-cpp its sigmoid, and both answer a Qwen3 reranker’s probability (its yes and no, softmaxed), the mapping’s `score` itself. Its tokenizer is BPE, SentencePiece’s (BPE or unigram: XLM-R’s) or WordPiece. |
| LiteRT-LM | `.litertlm` | An on-device language model’s, as Android and iOS apps run them with LiteRT-LM (Gemma’s): its KV cache’s length, its prefill signatures, the token that ends its turn, and its LLM metadata and tokenizer (SentencePiece’s, as Gemma’s, or Hugging Face’s, as Qwen’s and SmolLM’s), which go in `model-files`. |
| MNN-LLM | `config.json` (of its folder: `llm_config.json`, `llm.mnn`, `tokenizer.txt` or `tokenizer.mtok`) | An on-device language model’s, as MNN’s LLM engine runs it (`Llm::createLLM`: Android’s and iOS’s MNN apps): its context length (`max_all_tokens`), the token that ends its turn (its chat template’s, or its tokenizer’s stop token that ends a turn), and its configs and tokenizer, which go in `model-files`. axx tokenizes as MNN’s engine does, of each of its tokenizers: Hugging Face’s BPE (`tokenizer.txt` of type 3: its expression of bytes), tiktoken’s (type 1: the longest token first), SentencePiece’s (type 0: a vocabulary MNN’s export wrote of spaces, which MNN’s encoder writes as their bytes) and its pipeline (`tokenizer.mtok`, a Hugging Face tokenizer’s in binary), held to MNN’s tokens of Qwen2.5’s, Llama 3.2’s, MiniCPM4’s and Gemma 3’s. |
| MLC (WebLLM) | `mlc-chat-config.json` (of its folder: `tensor-cache.json`, `tokenizer.json`, the weights’ shards) | A language model as MLC LLM compiles it (WebLLM’s, in a page, on its GPU; MLC LLM’s apps’): its architecture and quantization (the model library compiled for them runs the stub, as it runs the real model), its context window, the token that ends its turn (its chat template’s), its manifests of its weights’ records and its tokenizer, which go in `model-files`. The architectures of WebLLM’s models: llama’s (SmolLM2, Llama 3, TinyLlama), Mistral’s, Qwen 2’s, 3’s and 3.5’s, Phi-3’s (Phi-3.5, Phi-4 mini, Phi-3.5 vision’s text), Phi-2’s (and Phi-1.5’s), Gemma’s, Gemma 2’s and Gemma 3’s, OLMo 2’s, StableLM’s and GPT-NeoX’s (RedPajama), of each quantization WebLLM’s models come in: `q4f16_1`, `q4f32_1`, `q0f16`, `q0f32` (and `q3f16_1`). Not Ministral 3’s: MLC’s YaRN turns the two halves of each of its heads’ pairs by different angles, so that no head of it scores a key by its offset alone, as a stub’s heads do. |
| Foundation Models | (a Swift type) | None: the stub stands in for a type of the app’s own of the `LanguageModel` protocol, and its contract is written by hand (the system’s model, `SystemLanguageModel`, gets a stub of its API instead: `axx models contract ios.foundation-models`). Its format is `foundation-models`, of `details.name`, the stub’s Swift type, and `details.capabilities`, what it can do as `LanguageModelCapabilities` names it (without it, the system model’s: `guidedGeneration`, `toolCalling` and `vision`); its task is `text-generation`. It has no inputs or outputs: its stub answers a prompt. |
A contract of an app’s own Foundation Models model, written by hand:
models/notes/contract.json
```json
{
"format": {
"type": "foundation-models",
"details": { "name": "NotesModelStub" }
},
"task": "text-generation"
}
```
A model the platform provides has no file to read: its API names it, and `axx models contract` writes the API’s contract ([Models the platform provides](#models-the-platform-provides)):
```sh
axx models contract ios.vision.text -o models/label-reader/contract.json
```
A model that does not say a tensor’s type and shape (a TorchScript module’s, an MNN or ncnn model’s outputs’), or that the app gives otherwise (an open batch the app always gives one picture), takes them from `--input` and `--output`, by the tensor’s name, in quotes (shells read `[` and `?`):
```sh
axx models contract photo-check.pt --task classification --labels labels.txt \
--input 'images=float32[1,3,224,224]' --output 'output0=float32[1,1000]' \
-o models/photo-check/contract.json
```
When the model says none and the flags do not either, the command names each tensor and the flags that give it.
What the model carries besides its graph (a TFLite model’s metadata and label files) goes in a `model-files` folder beside the contract, and the stub carries it too: an app that reads its labels from the model’s metadata reads the stub’s.
A language model’s contract keeps its tokenizer, so a stub reads a prompt in the real model’s tokens and writes its answers in them: its metadata (1.7 MB for SmolLM2’s, up to 10 MB for the largest vocabularies) goes in `model-files`. axx tokenizes as the model’s runtime does:
* a GGUF model’s as llama.cpp does, with its byte-level BPE tokenizers (Llama 3, Qwen 2.5, SmolLM2 and the others llama.cpp splits words for alike), SentencePiece (Mistral, Gemma 3, Phi-3) and WordPiece (BERT’s embedding models: bge, all-MiniLM, nomic-embed), held to llama.cpp’s tokens of those models;
* an ONNX, ExecuTorch or safetensors model’s as Hugging Face’s tokenizers do (`tokenizer.json`), with BPE models’ (byte-level, or of `▁` for spaces with their bytes’ fallback), their normalizers (NFC, NFKC) and their pre-tokenizers’ splits, held to transformers.js’s tokens of Llama 3.2, Gemma 3, Phi-4, LFM2, SmolLM2, Qwen 2.5 and Qwen 3.
An ONNX language model’s stub runs in transformers.js (pages, Electron apps, Node.js services) and in ONNX Runtime GenAI (Windows, Android and iOS apps, services), with the real model’s configs: each runtime keeps the model’s cache its own way, and the stub keeps up with both.
A safetensors text embedding model’s stub embeds in sentence-transformers and in transformers’ `AutoModel`, 4.57 and 5 alike, exactly as its mappings say.
A safetensors language model’s stub runs in Hugging Face’s transformers (`AutoModelForCausalLM`’s `generate`, and the `text-generation` pipeline), 4.57 and 5 alike, in the real model’s folder.
An ExecuTorch language model’s stub runs in ExecuTorch’s text LLM runner (React Native ExecuTorch, ExecuTorch’s Android and iOS libraries), with the real model’s tokenizer: its prompt in one call, or a token a call (as the runner gives a program of fixed shapes its prompt), and a conversation the runner continues from chat to chat. An ExecuTorch speech recognizer’s stub (Whisper’s, as React Native ExecuTorch runs it) transcribes a sound of any length up to 30 seconds.
A Whisper’s stub, of its weights (safetensors, CTranslate2’s `model.bin`, whisper.cpp’s model file, OpenAI’s checkpoint), transcribes in transformers (its pipeline, in float32, float16 and bfloat16, on CPUs and on a Mac’s GPU), in faster-whisper (CTranslate2, in float32 and in int8, which rounds every layer’s inputs and weights to 8 bits, as faster-whisper runs on CPUs), in whisper.cpp (on CPUs and on Metal, of its flash attention) and in `openai-whisper` (`whisper.load_model` of the stub’s path: of a model’s name, it downloads the real one), of the language the runtime detects or the app gives, with timestamps or without.
A Tesseract stub is a small, float LSTM `.traineddata`, with whole-word unichars and no dictionaries. Tesseract’s own recognizer matches the picture and decodes the answer. The model’s config reads the whole picture as one raw line (page segmentation mode 13) and does not try a second, inverted recognition. An application that sets its own page segmentation mode overrides that config, as it does with any traineddata.
A Piper voice stub is an ONNX model with its `.onnx.json` beside it. The sidecar keeps the real voice’s settings but uses `phoneme_type: "text"`, so codepoints are the phonemes and no eSpeak installation is needed. Each mapping’s `audio` WAV is the answer the real PiperVoice synthesizes, resampled to the voice’s rate.
```sh
axx models contract SmolLM2-360M-Instruct-Q4_K_M.gguf -o models/description-triage/contract.json
axx models contract Llama-3.2-1B-Instruct-ONNX/onnx/model_q4f16.onnx -o models/customs-draft/contract.json
```
A model that reads a text (a text classifier or embedder: BERT’s, MediaPipe’s) has its tokens’ ids for input: its contract’s `text` says which input, and how the app tokenizes the text, with the vocabulary it carries. `axx models contract` reads it from a TFLite model’s metadata (MediaPipe’s WordPiece and regex tokenizers), and from an ONNX model’s folder (its `tokenizer.json`, Hugging Face’s: WordPiece’s, as BERT’s normalizer has it, or BPE’s), with its task: a classifier of the labels its `config.json` names, or an embedder of its `last_hidden_state` and `sentence_embedding`. Give another ONNX model’s with `--vocab vocab.txt`, its WordPiece vocabulary.
An input of a sound’s samples (a classifier of sounds: YAMNet, a Create ML sound classifier) gets `audio`, its sample rate: `axx models contract` reads it from a TFLite model’s metadata and from a Create ML sound classifier’s (whose labels it reads from the classifier at the end of its pipeline); give it others’ (`"audio": {"sampleRate": 16000}`).
An input shaped like an image gets an image layout. Check what the app does with a picture before it gives it to the model, and say so in the contract:
| `image` key | What it says |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `layout` | The order of the input’s dimensions: `NCHW`, `NHWC`, or `CHW` and `HWC` without a batch. |
| `channels` | `RGB` (the default), `BGR` or `gray`. |
| `resize` | How the app fits a picture to the input’s size: `center-crop` (the default: the middle of it, at the input’s proportions) or `stretch`. |
| `crop` | The share of the fitted picture’s middle the app keeps, of each side: torchvision’s and timm’s evaluation transforms (the shorter side resized to 256, its middle 224 kept) keep `0.875`. The default keeps all of it. |
How the app scales the pixels (0 to 255, 0 to 1, a mean and a deviation for each channel) makes no difference: matching undoes it.
A detector’s contract says what each of its outputs holds, with `detection`. `axx models contract` reads it from a TFLite model’s metadata (MediaPipe’s detectors, TensorFlow Lite Model Maker’s), and `--task detection --labels labels.txt` names the outputs of others:
| `detection` | What the output holds | Detectors |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `boxes`, `classes`, `scores`, `count` | The objects, first: their boxes, their labels’ indexes (the labels the `classes` output’s), their scores, and how many there are. | SSD and EfficientDet with their post-processing (TensorFlow’s object detection API, the TFLite detection post-processing) |
| `anchors` | Each anchor’s box and its labels’ scores; the app suppresses the overlapping ones. | YOLO (v8 and later), as ONNX and TFLite export it |
| `locations`, `classScores` | Each anchor’s box, encoded against the anchor (`anchors`: the anchors’ file, the box coder’s scales), and each anchor’s labels’ scores. The app decodes the boxes. | MediaPipe’s object detector (EfficientDet-Lite) |
A box’s values are in the order `box.order` gives (`ymin,xmin,ymax,xmax`, TensorFlow’s, by default for `boxes`; `cx,cy,width,height` in pixels, YOLO’s, for `anchors`), as shares of the input picture’s size or, with `box.pixels`, in its pixels.
MediaPipe’s tasks stretch a picture to the model’s input: give its contract’s image `resize: stretch`.
A classifier’s output says what it holds with `activation`:
* `softmax`: probabilities that add up to 1 (the default);
* `sigmoid`: a probability for each label;
* `none`: logits, which the app turns into probabilities.
## 2. Map inputs to answers
[Section titled “2. Map inputs to answers”](#2-map-inputs-to-answers)
A mapping is a JSON file of the `mappings` folder, as WireMock’s stubs are, or one of several under `"mappings"`:
models/evidence-check/mappings/damage-photos.json
```json
{
"mappings": [
{
"name": "A crushed carton on the porch",
"input": {"image": "../../../features/damage-claims/evidence/crushed-box.png"},
"output": {"label": "carton", "score": 0.91}
},
{
"name": "A screenshot of the shop's order page",
"input": {"image": "../../../features/damage-claims/evidence/order-screenshot.png"},
"output": {"label": "web site", "score": 0.88}
}
]
}
```
A mapping’s `input` is what it answers:
* `image`: a picture (PNG, JPEG, GIF or WebP) beside the mapping or anywhere;
* `audio`: a sound (a WAV file), for an input of a sound’s samples (its first samples, as many as the input has, at its sample rate, resampled from the file’s), or of its features (a speech recognizer’s log-mel spectrogram, which the stub computes from the sound as the app’s processor does);
* `tensor`: the input’s values, as nested arrays or a JSON file of them;
* `text`: a text the prompt has, for a language model, or the text a text classifier or embedder (BERT’s) reads.
An input of several keys that answer alike lists them: `{"text": ["green gate", "wooden box"]}` answers a prompt that has either, and `{"image": ["parcel-front.jpg", "parcel-side.jpg"]}` either photo (a tensor’s values, a list of numbers, are one key). Each is a key of its own, which no other key of the stub may be.
Its `output` is the answer, in the terms of the model’s task:
* **A classifier** answers `label` (with `score`, 1 by default) or `scores`, by label.
* **A detector** answers `objects`: each object’s `label`, its `box` (`x`, `y`, `width` and `height`, shares of the picture’s width and height from its top left corner) and its `score` (1 by default). The stub gives them as the detector’s outputs hold them, and nothing else.
* **A segmenter** answers `mask`: a picture (beside the mapping, stretched to the output’s size) whose gray levels are each pixel’s label’s index (0 for the first label); or, for a segmenter of one label or none (a selfie segmenter’s person, a text detector’s probability map: PaddleOCR’s), each pixel’s confidence, 255 being 1. An output of `activation: none` holds logits, which the app takes a sigmoid of (a background remover’s: BiRefNet’s, RMBG’s): the stub gives each pixel the logit of its confidence, ±20 for 0 and 1. A segmenter of each label’s own confidence (a text detector’s of its text’s regions and the links between their characters: CRAFT’s, as EasyOCR and React Native ExecuTorch run it) answers `masks`: a picture a label, `{"region": "door-region.png", "affinity": "door-links.png"}`, each pixel’s gray level the label’s confidence; a label without one is 0.
* **An embedder** (`embedding`) answers `embedding`: the values of each output of its size, or a name. An output of each token’s (`last_hidden_state`) has them at every token, so the app’s mean of them is the embedding. A name is a unit vector of its own (of SHA-256 of the name), so the texts of the mappings that answer one name are one embedding apart (their cosine 1), and those of two names nearly at right angles: a search that should find an article for a question maps both to one name.
* **A language model** (`text-generation`) answers `text`, what it writes; `json` or `toolCalls`, a JSON value or an array of calls it writes compactly (a structured output); or `refusal`, its refusal as text.
* **A text-to-speech model** answers `audio`: a WAV file, resampled to the voice’s sample rate and written as the model’s fixed waveform, with silence after it or its end trimmed.
* **A text recognizer** (`text-recognition`: CRNN’s, PaddleOCR’s, EasyOCR’s) answers `text`, what it reads, and `score` (1 by default): its output’s steps hold each character’s class, a blank between two alike, then blanks, as CTC decoding reads them. Its contract’s labels are its classes, the blank an empty one (`--labels`: a file of a character a line, the blank an empty line).
* **A speech recognizer** (`speech-to-text`) answers `text`, what it hears said. It writes it after a space, as Whisper does, and, when the app asks for timestamps, from 0 to where the sound ends. A text with a symbol the recognizer’s runtime never lets it write (Whisper’s `generation_config.json` suppresses `:`, `#`, brackets and others) stops the build, named.
* **Any model but a language model** can give its outputs’ values themselves, as `tensors`, by output name. An output an answer does not give is zeros.
One mapping is the default. It answers every input no other mapping matches. A model cannot refuse an input, so a stub always has one:
models/evidence-check/mappings/default.json
```json
{
"name": "Any other photo: the model is unsure",
"default": true,
"output": {"label": "carton", "score": 0.3}
}
```
A language model’s mappings, in the parcel-claims example:
models/description-triage/mappings/descriptions.json
```json
{
"mappings": [
{
"name": "The customer changed their mind",
"input": {"text": "changed their mind"},
"output": {"json": {"transitDamage": false, "summary": "The customer changed their mind."}}
}
]
}
```
### Which input matches a mapping
[Section titled “Which input matches a mapping”](#which-input-matches-a-mapping)
**A text** a text classifier or embedder reads matches the first mapping whose text it has, as words, in the model’s tokens: the stub compares them with the input’s ids. A BPE tokenizer’s tokens of a text depend on what is around it (a space before it, a comma after it), so the stub compares every way the text may be tokenized. **A prompt** matches the first mapping whose text it has, as words: `changed their mind` matches “The customer changed their mind and sent it back”, wherever the app puts the shop’s description in its prompt (its system prompt, its chat template, quotes around it), but not “changed their minds”. Letters’ cases count. A prompt that has no mapping’s text gets the default’s answer. The stub writes the answer token by token, then ends its turn, at any temperature, and through any grammar the answer keeps to.
A language model reads its answer as it writes it, so an answer may not have the text of a mapping before it: the build stops and names both.
**Any other input** matches the mapping whose input it is most like, when it is alike enough. The contract’s `match.similarity` sets how alike, from 0 to 1 (0.9 by default). Similarity is measured on a reduced form of each input:
* **A picture** is pooled to a grid of at most 16×16, each channel standardized: a phone camera’s noise, another resolution or a slightly different crop still match.
* **A sound** is reduced to its spectrum: its power in 32 bands as wide in mels (finer low, as hearing is), averaged over frames of about 256 samples, as logs, standardized. The sound a little later in the app’s window, or quieter or louder, still matches.
* **A speech recognizer’s features** (a sound’s log-mel spectrogram, Whisper’s) are pooled to 16 bands over each tenth of a second, each tenth’s bands centered and scaled to the same loudness (what is said, not how loud), a tenth of no sound (silence, a faint hum) left out, then the whole scaled alike. The same recording, louder, quieter or a little noisy, matches; the same speaker’s other words do not.
* **Any other tensor** is standardized as a whole.
The same input gets the same answer every time. A mapping’s picture is the picture a scenario gives the app (the photo it uploads, the camera’s scene), so use the same file in both.
## 3. Build the stub
[Section titled “3. Build the stub”](#3-build-the-stub)
```sh
axx models build models/evidence-check -o ../infra/models/evidence-check.onnx
```
The stub is a model of the contract’s format, with the real model’s inputs, outputs and version, and the files it carries. It is a fraction of the real model’s size: a stub of an ONNX MobileNetV2 (14 MB) is 30 KB, of a TFLite MobileNet (4.3 MB) 38 KB, of Apple’s Core ML MobileNetV2 (24.7 MB) 65 KB, of SmolLM2-360M-Instruct (270 MB) 6.7 MB. A language model’s stub is most of all its vocabulary: up to 35 MB for the largest (Gemma’s 262,000 tokens). An ONNX language model’s stub is its graph alone, 28 to 52 KB, and an ExecuTorch one’s its program alone, 59 KB for SmolLM2’s (173 MB): its tokenizer and configs stay the real model’s files, in its folder.
A stub of an ONNX model whose weights are in files beside it (`model_q4f16.onnx_data`) comes with empty files of their names: a runtime that loads them with the model (transformers.js, as the model’s `config.json` says) finds them, and the stub has no weights in them.
A mistake stops the build and names the mapping:
* an unknown label;
* a tensor of the wrong size;
* a picture of one color, which is like no input;
* two mappings of the same input;
* a second default, or none.
Keep the stub with the app’s sources, where step 4 puts it, as you keep generated files. A stub is built the same, bit for bit, on every machine: build it again when its contract or mappings change (with the project’s other generation steps), and have CI build it and check that nothing changed. The app’s build then needs neither axx nor the real model.
## 4. Put it where the app gets its model
[Section titled “4. Put it where the app gets its model”](#4-put-it-where-the-app-gets-its-model)
The stub takes the real model’s place where the app already gets its model. Only the test build or the test deployment changes:
| The app gets its model | The stub goes | Example |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| From its own package: an Android app’s assets, an iOS app’s bundle | Into the test build’s package, under the real model’s name | The parcels example’s couriers’ apps |
| From its own files: a model it downloaded the first time it needed it, and keeps | Into the app’s files, before the step that starts the app: a folder of the app’s (the files pack’s `owner: app:`) and its “is a copy of” step. A mobile app is reset before each scenario, and starts only when a step starts it | The parcels example’s couriers’ Android app (its delivery notes’ model, and its damage reports’ MNN-LLM model, a folder of files) |
| From a file its configuration names: a service’s model path | Into the test deployment, at that path | The services of the parcel-claims, carrier-billing, customs-clearance and parcel-support examples |
| From the system: a model a distribution’s package installs (Debian’s tesseract-ocr: `/usr/share/tesseract-ocr/5/tessdata/eng.traineddata`) | Over the package’s file, in the test deployment’s container (a file mounted in its place) | The parcel-support example’s service (its label reader) |
| From a model server that serves the model to it over the network (TF Serving: `//saved_model.pb`; OpenVINO Model Server: `//model.xml`) | Into the test deployment’s model folder, as the model’s version. The app then reaches its model over HTTP, which the WireMock image’s model mock answers too | |
| From an engine built for its GPU (TensorRT’s `.engine` or `.plan`, which Triton’s TensorRT backend serves as `model.plan`), built from an ONNX model | Built, from the ONNX stub of the ONNX model’s contract, as the deployment builds the real model’s engine (`trtexec --onnx`, with its shapes and precision), on the GPU and TensorRT the test deployment runs: an engine is of one GPU and one version of TensorRT. An engine of half precision rounds the stub’s values, its answers the same | |
| From a URL its configuration names, which it downloads the model from | To WireMock, which serves the stub at that URL: a mapping whose response’s `bodyFileName` is the stub | |
| From the platform, through its API (Vision’s requests, ML Kit’s clients, Foundation Models’ sessions of the system’s model and of Apple’s server model, a browser’s `LanguageModel`) | A stub of the API (`.axxmodel`), into the app’s `axx-models` folder, before the step that starts the app or opens its first page: see [Models the platform provides](#models-the-platform-provides) | The parcels example’s couriers’ apps (their photo checks; the iOS app’s delivery notes and missed-delivery cards) and its web portal |
| From a type of its own of Foundation Models’ `LanguageModel` protocol (iOS, macOS and visionOS 27), which its sessions take (`LanguageModelSession(model:)`) | Into the test build’s sources: the stub is a Swift type of the protocol, which the test build gives its sessions in place of the app’s own | The parcels example’s couriers’ iOS app (its notes model, on an iPhone without Apple Intelligence) |
| From a model’s folder (transformers.js’s: `config.json`, the tokenizer’s files, `onnx/model_q4f16.onnx`; ONNX Runtime GenAI’s: `genai_config.json`, the tokenizer’s files, `model.onnx`; transformers’: `config.json`, the tokenizer’s files, `model.safetensors`; faster-whisper’s: `config.json`, `tokenizer.json`, `vocabulary.txt`, `model.bin`; MNN-LLM’s: `config.json`, `llm_config.json`, `llm.mnn`, `llm.mnn.weight`, `embeddings_bf16.bin` (or the embeddings in `llm.mnn.weight`), `tokenizer.txt` or `tokenizer.mtok`; MLC’s, which a web app’s WebLLM fetches from the URL its app config gives the model: `mlc-chat-config.json`, `tensor-cache.json`, `tokenizer.json`, `params_shard_*.bin`) | Into the test build’s copy of the folder, in place of its model’s file (and, of a transformers language model’s, its `config.json`), beside the real tokenizer and configs. An MNN-LLM stub is the whole folder: `axx models build -o /llm.mnn` writes the stub’s files and, beside them, the real `config.json` and tokenizer. An MLC stub is the whole folder too (`axx models build -o `), served where the test build’s app config says the model is, and the real model’s library runs it | The parcels example’s shop portal (its tracking page’s WebLLM model too), the parcel-support example’s service (faster-whisper, sentence-transformers and transformers) |
A framework that runs the app’s own model gets it the same way: the couriers’ iOS app runs its model with Vision, from its bundle. A sound classifier an app runs with Sound Analysis (`SNClassifySoundRequest(mlModel:)`, a Core ML model of a sound’s samples, as Create ML makes them) is the app’s too: the couriers’ iOS app hears the doorbell or a knock with one when no one is home. Sound Analysis cuts what the microphone hears into the model’s windows and runs the stub on each, as it runs the real model, and a mapping’s recording matches each window of it.
**An Android app.** The debug build’s assets (`src/debug/assets`) have the stub, under the real model’s name, and a release build fetches the real model. The couriers’ app runs it with ML Kit’s custom image labeling, which reads the labels from the model’s metadata and stretches a photo to the model’s input (its contract says `resize: stretch`):
```sh
axx models build models/photo-check-android -o ../courier/android/app/src/debug/assets/photo-check.tflite
```
Its damage check is the company’s own PyTorch model, saved for PyTorch Mobile, which PyTorch Android Lite runs: the debug build’s assets have its stub, and a release build takes the company’s model from the path its build gives (`-PdamageCheckModel=`):
```sh
axx models build models/damage-check-android -o ../courier/android/app/src/debug/assets/damage-check.ptl
```
**An iOS app.** The stub is kept with the app’s sources (`Courier/Models/PhotoCheck.mlmodel`), and a build phase compiles it into the app in a Debug build (`xcrun coremlcompiler compile`, as Xcode compiles a model), and the real model in a Release build:
```sh
axx models build models/photo-check-ios -o ../courier/ios/Courier/Models/PhotoCheck.mlmodel
```
**A model of the app’s own, of Foundation Models’ protocol.** From iOS 27 a session takes a model of the app’s (`LanguageModelSession(model:)`, of the `LanguageModel` protocol). An app that ships a type of its own of the protocol gets a stub of that type: a Swift file, kept with the app’s sources, of the type the contract names (`format.details.name`, and `format.details.capabilities`, what the app’s model can do: the system model’s unless it says), which the Debug build compiles and gives its sessions, and which Release builds leave out (`EXCLUDED_SOURCE_FILE_NAMES = CourierNotesModelStub.swift`). The couriers’ iOS app summarizes a long delivery note with a model of its own on an iPhone without Apple Intelligence: in a Release build, SmolLM2 (a GGUF it bundles) on llama.cpp, behind its type `CourierNotesModel`; in a Debug build, which leaves that type and llama.cpp out, the stub:
```sh
axx models build models/delivery-note-own-ios -o ../courier/ios/Courier/Models/CourierNotesModelStub.swift
```
Courier/DeliveryNotes.swift (excerpt)
```swift
let system = SystemLanguageModel.default
if system.isAvailable {
return try await respond(LanguageModelSession(model: system, instructions: instructions), note)
}
// An iPhone without Apple Intelligence: the app's own model.
#if DEBUG
let own = CourierNotesModelStub() // the stub
#else
guard let own = CourierNotesModel() else { return nil } // SmolLM2, on llama.cpp
#endif
return try await respond(LanguageModelSession(model: own, instructions: instructions), note)
```
Its scenario stubs the system’s model as an iPhone without Apple Intelligence (see [Models the platform provides](#models-the-platform-provides)), so that the app takes its own.
The stub builds with Xcode 27 (Swift 6.4), and runs on iOS, macOS and visionOS 27: an older Xcode builds its file empty. Sessions of the system’s model (`SystemLanguageModel`) need no stub in the app’s sources: see [Models the platform provides](#models-the-platform-provides).
**A service.** The test deployment mounts the stubs at the paths the service loads its models from. The parcel-claims service checks photos with ONNX Runtime, and triages the shops’ descriptions of damage with a language model it runs with llama.cpp:
infra/compose.yaml (excerpt)
```yaml
claims:
environment:
CLAIMS_PHOTO_MODEL: /models/evidence-check.onnx
CLAIMS_TRIAGE_MODEL: /models/description-triage.gguf
volumes:
- ./models/evidence-check.onnx:/models/evidence-check.onnx:ro
- ./models/description-triage.gguf:/models/description-triage.gguf:ro
```
**A web page.** The parcels example’s shop portal drafts a parcel’s customs description with a small language model the page runs itself (transformers.js, on ONNX Runtime’s WebAssembly), from the folder the service serves its models from. The test deployment mounts a folder of the real model’s configs and tokenizer, and the stub in place of its ONNX file:
```sh
axx models build models/customs-draft -o ../infra/portal-models/customs-draft/onnx/model_q4f16.onnx
```
It cuts a photo of a parcel’s contents out of its background the same way (transformers.js’s `background-removal`, of BiRefNet\_lite, whose image processor’s configuration the folder keeps beside the stub); a screenshot of the cutout checks the page:
```sh
axx models build models/contents-cutout -o ../infra/portal-models/contents-cutout/onnx/model_fp16.onnx
```
infra/compose.yaml (excerpt)
```yaml
app:
environment:
PARCELS_PORTAL_MODELS: /var/lib/parcels/portal-models
volumes:
- './portal-models:/var/lib/parcels/portal-models:ro'
```
Then the scenarios test what the app does with each answer, with the steps they already use:
```gherkin
Scenario: A photo that shows no parcel goes to a person
Given a seeds/PX-4111.yaml dynamodb seed
And a POST request to /api/claims
And a request payload using an application/json content example
And the request payload properties are:
| parcel | PX-4111 |
| reason | DAMAGED |
And the request is executed
When the evidence/order-screenshot.png file is uploaded to the claim-evidence s3 bucket as claims/CLM-4111/order-screenshot.png
Then within 30s the claim-reviews s3 bucket has an object named CLM-4111/order-screenshot.png
And the claims dynamodb table has an item where:
| id | CLM-4111 |
| status | REFERRED |
| note | the photo shows no parcel: it looks like a web site (88%) |
```
### The examples’ stubs
[Section titled “The examples’ stubs”](#the-examples-stubs)
Each of axx’s examples runs its stubs embedded, in the real runtime inside the app’s own process (or on the device, or in the page), the way the app runs the real model, and its acceptance suite passes with them:
| Example | What the model does | Runtime | Format |
| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| [`parcel-claims`](https://github.com/nimbusxr/axx/tree/main/examples/parcel-claims) | checks that an evidence photo shows a parcel | ONNX Runtime, in the service | ONNX |
| | triages a damage claim’s description | llama.cpp, in the service | GGUF |
| | transcribes a customer’s voice note | whisper.cpp, in the service | whisper.cpp’s (ggml) |
| [`carrier-billing`](https://github.com/nimbusxr/axx/tree/main/examples/carrier-billing) | checks that a repack’s photo shows a parcel | TensorFlow (its C library), in the service | TensorFlow’s SavedModel |
| [`customs-clearance`](https://github.com/nimbusxr/axx/tree/main/examples/customs-clearance) | names what an inspection’s photo shows | OpenVINO (its C library), in the service | OpenVINO’s IR |
| [`parcel-support`](https://github.com/nimbusxr/axx/tree/main/examples/parcel-support) | hears whether anyone speaks in a voicemail | MediaPipe (its audio classifier), in the service | TFLite (YAMNet) |
| | transcribes a voicemail (a telephone’s 8 kHz) | faster-whisper (CTranslate2); OpenAI’s whisper, transformers and parakeet.cpp (its command), its profiles | CTranslate2’s; OpenAI’s `.pt`; safetensors; Parakeet’s GGUF (NVIDIA’s Parakeet TDT-CTC 110M) |
| | finds the help-centre article that answers a message | sentence-transformers; llama.cpp (llama-cpp-python), its profile | safetensors; GGUF |
| | reranks the articles near a message | llama.cpp (llama-cpp-python), in the service | GGUF |
| | drafts a reply from the article | transformers; ONNX Runtime GenAI, its profile | safetensors; ONNX (GenAI’s model builder’s) |
| | speaks the confirmation a call-back plays to a customer who left a voicemail | Piper (its command-line program), in the service | Piper’s voice (`.onnx` and its `.onnx.json`) |
| | reads the reference on a parcel’s label, from a photo of its reference line | Tesseract (the system’s: Debian’s tesseract-ocr), from the service | Tesseract’s traineddata |
| | says what a customer’s photo of their parcel shows | PyTorch (TorchScript); ncnn, MNN, ExecuTorch (XNNPACK) and Keras (on PyTorch), its profiles | TorchScript; ncnn’s `.param`; `.mnn`; `.pte`; `.keras` |
| [`parcels`](https://github.com/nimbusxr/axx/tree/main/examples/parcels) | checks a proof-of-delivery photo, in the couriers’ Android app | ML Kit (custom image labeling, of the app’s own model) | TFLite |
| | checks a proof-of-delivery photo, in the couriers’ iOS app | Core ML (Vision) | Core ML |
| | refuses a proof-of-delivery photo of the parcel cut off at its edge, in the couriers’ iOS app | Core ML (Vision), the app’s own object detector (Create ML’s) | Core ML (a detector’s pipeline) |
| | summarizes a recipient’s delivery note on an iPhone without Apple Intelligence, in the couriers’ iOS app | the app’s own model, of Foundation Models’ `LanguageModel` protocol (SmolLM2 on llama.cpp, in a Release build) | a Swift type of `LanguageModel` |
| | hears the doorbell or a knock when no one is home, and records the attempt with it, in the couriers’ iOS app (the microphone step’s recording, played into the app’s own capture) | Core ML (Sound Analysis), the app’s own sound classifier (Create ML’s) | Core ML (a pipeline classifier) |
| | the same, in the couriers’ Android app | MediaPipe (its audio classifier), of the app’s YAMNet | TFLite |
| | reports a parcel damaged with a photo of the damage, which the company’s own damage classifier checks, in the couriers’ Android app | PyTorch Android Lite (PyTorch Mobile’s lite interpreter) | PyTorch Mobile (`.ptl`) |
| | writes out a courier’s note of a parcel’s damage for the damage report, in the couriers’ Android app | MNN’s LLM engine (MNN-LLM, built into the app’s native code), of Gemma 3 270M | MNN-LLM |
| | drafts a parcel’s customs description, in the shop portal’s page | transformers.js (ONNX Runtime Web) | ONNX |
| | cuts a photo of a parcel’s contents out of its background, in the shop portal’s page | transformers.js (ONNX Runtime Web), its background removal (BiRefNet\_lite) | ONNX (a mask of logits) |
| | checks a recipient’s photo of a damaged parcel, in the tracking page | TensorFlow\.js | TensorFlow\.js graph model |
| | suggests why a recipient sends a parcel back, from their own words, in the tracking page | WebLLM (SmolLM2-135M, on the browser’s GPU through WebGPU) | MLC (WebLLM), of float32s (`q4f32_1`), which every GPU runs |
| | summarizes a recipient’s delivery note, in the couriers’ Android app | LiteRT-LM; Gemini Nano (ML Kit’s Prompt API), on a phone that has it | `.litertlm`; a stub of its API (`.axxmodel`) |
| | summarizes a recipient’s delivery note, in the couriers’ iOS app | Foundation Models (iOS 27), the system’s model | a stub of its API (`.axxmodel`) |
| | drafts the card a courier leaves when no one is home, in the couriers’ iOS app | Foundation Models (iOS 27), Apple’s server model (Private Cloud Compute) | a stub of its API (`.axxmodel`) |
| | tells a photo of the recipient’s signed note from a proof-of-delivery photo, reads the parcel’s label in one, sees a dog beside the parcel and blurs out the people in it, in the couriers’ iOS app | Vision: `VNClassifyImageRequest`, `VNRecognizeTextRequest`, `VNRecognizeAnimalsRequest`, `VNDetectHumanRectanglesRequest`, `VNGeneratePersonSegmentationRequest` | stubs of their APIs (`.axxmodel`) |
| | the same, and refuses a photo of the parcel cut off at its edge, in the couriers’ Android app | ML Kit: image labeling, text recognition, object detection, selfie segmentation | stubs of their APIs (`.axxmodel`) |
| | reads a parcel’s reference off the depot’s label camera’s scan, keeps a scan only when it shows a parcel and blurs out the people in it, in the depot desk’s Mac builds (AppKit, SwiftUI) | Vision: `VNRecognizeTextRequest`, `VNClassifyImageRequest`, `VNDetectHumanRectanglesRequest`, `VNGeneratePersonSegmentationRequest` | stubs of their APIs (`.axxmodel`) |
| | takes the clerk’s dictated handover note, and sums up the day for the next shift, on the Mac or on Apple’s servers, in the depot desk’s Mac builds | Speech (`SFSpeechRecognizer`, of `AVAudioEngine`’s audio); Foundation Models (macOS 27), the system’s model and Apple’s server model | stubs of their APIs (`.axxmodel`) |
| | reads the handover rules aloud, in the depot desk’s Windows builds (Windows Forms, WPF, WinUI); reads a parcel’s reference off a scan, keeps a scan only when it shows a parcel, and sums up the day for the next shift, in its WinUI build | SAPI (System.Speech), with a Piper voice; the Windows AI APIs: `TextRecognizer`, `ImageDescriptionGenerator`, `LanguageModel` (Phi Silica) | a Piper voice stub; stubs of their APIs (`.axxmodel`) |
| | reads a parcel’s reference off a scan with the system’s Tesseract, and reads the handover rules aloud through the desktop’s speech service, in the depot desk’s GTK builds | Tesseract’s language data; speech-dispatcher, with a Piper voice | a Tesseract stub (`eng.traineddata`); a Piper voice stub |
| | takes a courier’s dictated note, in the couriers’ iOS and Android apps (the microphone step’s recording, played into the app’s own capture) | Speech (`SFSpeechRecognizer`, of `AVAudioEngine`’s audio); Android’s `SpeechRecognizer` | stubs of their APIs (`.axxmodel`) |
| | puts a recipient’s delivery instructions in English, checks their spelling and takes them spoken, in the tracking page | the browser’s built-in AI: `LanguageDetector`, `Translator`, `Proofreader`, `SpeechRecognition` | stubs of their APIs (`.axxmodel`) |
| | fills in the address a customer wrote, drafts a message to a parcel’s recipient and makes it shorter, and puts a long delivery note in a line, in the shop portal | the browser’s built-in AI: the Prompt API (`LanguageModel`, JSON), `Writer`, `Rewriter`, `Summarizer` | stubs of their APIs (`.axxmodel`) |
### Models the platform provides
[Section titled “Models the platform provides”](#models-the-platform-provides)
A model the platform provides is the platform’s: the app calls an API (Vision’s `VNRecognizeTextRequest`, ML Kit’s `TextRecognition`, Foundation Models’ sessions of `SystemLanguageModel` and of `PrivateCloudComputeLanguageModel`, a browser’s `LanguageModel`), and the platform runs its own model, in no file of the app’s. Such a model gets a stub of its API: a `.axxmodel` file, built from a contract and mappings as every stub is, which the scenario puts in the app’s `axx-models` folder. A pack of the platform’s then answers the app’s calls from it, in the app’s test runs:
| Pack | Where it answers | How |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `models-ios` | A Debug app on the iOS Simulator (iOS 16 or later; Foundation Models: iOS 27 and Xcode 27) | A library the simulator loads into the app when axx launches it (`DYLD_INSERT_LIBRARIES`), which axx compiles with Xcode and caches |
| `models-macos` | A Mac app’s test build, signed without the hardened runtime or with its two exceptions (below), on macOS 15 or later (Foundation Models: macOS 27) | The same library, which macOS loads into the app when axx launches it (`DYLD_INSERT_LIBRARIES`) |
| `models-linux` | A Linux app in the desktops `desktop-linux` runs | Tesseract’s documented `TESSDATA_PREFIX`, and speech-dispatcher’s configuration in the app’s home, which speaks with Piper |
| `models-windows` | A Windows app in the desktops `desktop-windows` runs; for the Windows AI APIs, a self-contained Windows App SDK build with package identity | A SAPI voice of axx’s, which speaks with Piper, made the user’s default voice for the scenario; and a library of axx’s in place of the app’s own Windows AI API files, which axx builds with MSVC and C++/WinRT |
| `models-android` | A debuggable app on the Android emulator (Android 8 or later) | An agent attached to the app as it starts (ART’s tooling interface, `am start --attach-agent`), which axx builds with the SDK’s NDK and caches |
| `models-web` | The browsers the `web-core` pack runs | A script the browser runs in the app’s pages before their own (Playwright’s init scripts) |
The app’s code does not change, and nothing changes in its release builds or on other devices. Each pack needs `models-core`, `app-core` and its platform’s pack (`mobile-ios`, `desktop-macos`, `desktop-linux`, `desktop-windows`, `mobile-android`, `web-core`); the `files` pack copies the stub.
**A Mac app’s test build.** macOS ignores `DYLD_INSERT_LIBRARIES` in an app it signs with the hardened runtime, which would leave the app with Apple’s models instead of the stubs. A Mac app’s test build is signed without it, or with Apple’s two exceptions for it, in its Debug entitlements: `com.apple.security.cs.allow-dyld-environment-variables` and `com.apple.security.cs.disable-library-validation`. axx checks the app before it starts it, and stops the scenario if macOS would keep the library out (`AXX-E0704`). A Mac app’s `axx-models` folder is in its `./` files, its home’s `Library/Application Support`. A Mac has no test’s grant of speech recognition or the microphone, as a simulator has: a stubbed recognizer, and the microphone axx feeds, are authorized.
**1. The contract** is the API’s: name it, and `axx models contract` writes it. A labeler’s takes the labels its mappings answer:
```sh
axx models contract ios.vision.text -o models/label-reader/contract.json
axx models contract android.mlkit.image-labeling --labels labels.txt -o models/hazards/contract.json
```
**2. The mappings** are as every stub’s: pictures or recordings and their answers, or texts a prompt has, and the default.
**3. Build it**, and keep it with the acceptance project:
```sh
axx models build models/label-reader -o models/label-reader.axxmodel
```
**4. Put it in the app’s `axx-models` folder** with the files pack’s steps, before the step that starts the app (or opens its first page). A mobile app reads its folder when it starts, and again when it restarts; a browser’s page, when it opens.
```gherkin
Scenario: A recipient's long delivery note is summarized for the courier
Given a seeds/courier-app-ios-note.yaml db seed
And the courier-models folder with the following properties:
| owner | app:courier |
| path | axx-models |
And the delivery-note.axxmodel file in the courier-models folder is a copy of the models/delivery-note.axxmodel file
When the courier app is launched
```
| Pack | API (`axx models contract `) | What the app calls | Task |
| ---------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| `models-ios` | `ios.speech` | `SFSpeechRecognizer`: URL and PCM buffer requests | `speech-to-text` |
| | `ios.vision.classify` | `VNClassifyImageRequest` | `classification` |
| | `ios.vision.text` | `VNRecognizeTextRequest` | `text-recognition` |
| | `ios.vision.humans` | `VNDetectHumanRectanglesRequest` | `detection` |
| | `ios.vision.animals` | `VNRecognizeAnimalsRequest` (Vision’s `Cat` and `Dog`) | `detection` |
| | `ios.vision.person-segmentation` | `VNGeneratePersonSegmentationRequest` | `segmentation` |
| | `ios.foundation-models` | `LanguageModelSession` of `SystemLanguageModel`: its availability, responses and streams | `text-generation` |
| | `ios.private-cloud-compute` | `LanguageModelSession` of `PrivateCloudComputeLanguageModel`, Apple’s server model: its availability, responses and streams. A session of a model of the app’s own is Foundation Models’, untouched | `text-generation` |
| `models-macos` | `macos.speech` | `SFSpeechRecognizer`: URL and PCM buffer requests | `speech-to-text` |
| | `macos.vision.classify` | `VNClassifyImageRequest` | `classification` |
| | `macos.vision.text` | `VNRecognizeTextRequest` | `text-recognition` |
| | `macos.vision.humans` | `VNDetectHumanRectanglesRequest` | `detection` |
| | `macos.vision.animals` | `VNRecognizeAnimalsRequest` (Vision’s `Cat` and `Dog`) | `detection` |
| | `macos.vision.person-segmentation` | `VNGeneratePersonSegmentationRequest` | `segmentation` |
| | `macos.foundation-models` | `LanguageModelSession` of `SystemLanguageModel`: its availability, responses and streams | `text-generation` |
| | `macos.private-cloud-compute` | `LanguageModelSession` of `PrivateCloudComputeLanguageModel`, Apple’s server model, as on iOS | `text-generation` |
| `models-windows` | `windows.ai.text-recognizer` | The Windows AI APIs’ `TextRecognizer` (the Windows App SDK’s `Microsoft.Windows.AI.Imaging`): its ready state, and a picture’s text, one line of its words | `text-recognition` |
| | `windows.ai.image-description` | `ImageDescriptionGenerator`: a picture’s description, of every kind, streamed as its progress | `text-generation` (a picture’s mappings) |
| | `windows.ai.object-extractor` | `ImageObjectExtractor`: its mask of a picture (and its hints’), of the picture’s size, whatever the hint | `segmentation` |
| | `windows.ai.language-model` | `LanguageModel`, Phi Silica (`Microsoft.Windows.AI.Text`): responses, of a context’s system prompt too, streamed as their progress (not embedding vectors) | `text-generation` |
| | `windows.ai.text-summarizer` | `TextSummarizer`: texts, paragraphs, and conversations (each message a line, `participant: message`) | `text-generation` |
| | `windows.ai.text-rewriter` | `TextRewriter`: its tones, and a custom tone (the text, then the tone, a line each) | `text-generation` |
| | `windows.ai.text-to-table` | `TextToTableConverter`: an answer’s `json` is the table’s rows, each an array of its columns | `text-generation` |
| `models-android` | `android.speech` | `SpeechRecognizer`, and its on-device factory | `speech-to-text` |
| | `android.mlkit.image-labeling` | ML Kit’s `ImageLabeling`, of its own model | `classification` |
| | `android.mlkit.text-recognition` | ML Kit’s `TextRecognition` | `text-recognition` |
| | `android.mlkit.object-detection` | ML Kit’s `ObjectDetection` (its five categories) | `detection` |
| | `android.mlkit.segmentation` | ML Kit’s selfie `Segmentation` | `segmentation` |
| | `android.gemini-nano` | ML Kit’s Prompt API (`Generation`): responses and streams | `text-generation` |
| `models-web` | `web.speech` | `SpeechRecognition` and `webkitSpeechRecognition` | `speech-to-text` |
| | `web.language-model` | The Prompt API’s `LanguageModel`: prompts, history, clones and streams | `text-generation` |
| | `web.summarizer` | `Summarizer` | `text-generation` |
| | `web.writer` | `Writer` | `text-generation` |
| | `web.rewriter` | `Rewriter` | `text-generation` |
| | `web.translator` | `Translator`: translations and streams | `text-generation` |
| | `web.language-detector` | `LanguageDetector`: an answer’s `text` is the language (confidence 1), or its `json` the detector’s list of languages and confidences | `text-generation` |
| | `web.proofreader` | `Proofreader`: an answer’s `text` is the corrected text (its correction, the part that changed), or its `json` the proofreader’s own result | `text-generation` |
**A Linux desktop’s models.** A Linux app takes its system’s models from files its distribution installs, not from an API: Tesseract’s language data, and the voices of the desktop’s speech service, speech-dispatcher. Their stubs are those files, built from a contract of their format: a Tesseract language (`eng.traineddata`, of the `tesseract` format) and a Piper voice (its `.onnx` and `.onnx.json`, of the `piper` format). In the app’s `axx-models` folder, `models-linux` makes the app’s Tesseract read the language from there (`TESSDATA_PREFIX`), and gives the app’s speech-dispatcher the voice, which Piper speaks (its `piper` program, on the desktop’s PATH) with no sound.
**A Windows desktop’s voice.** A Windows app speaks with SAPI, through System.Speech’s `SpeechSynthesizer` or SAPI’s own `SpVoice`, in the voice the user chose. With a Piper voice stub in the app’s `axx-models` folder (its `./` files, its home’s `AppData\Roaming`), `models-windows` adds a voice of its own to SAPI’s, `axx voice`, and makes it the user’s default for the scenario: an engine of axx’s, which speaks with Piper, built with the .NET SDK (8 or later) into axx’s cache. SAPI lists the voices in `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech\Voices\Tokens` only, so axx runs as an administrator, as GitHub’s Windows runners do, or as a user who may create keys there; otherwise the app’s start stops with `AXX-E0704`. The scenario’s end takes the voice out and puts the user’s own default back.
**The Windows AI APIs.** A Windows app calls the Windows AI APIs (`TextRecognizer`, `ImageDescriptionGenerator` and `ImageObjectExtractor`, of `Microsoft.Windows.AI.Imaging`; the language model, Phi Silica, and its skills, of `Microsoft.Windows.AI.Text`) through the Windows App SDK, whose files a self-contained build (`WindowsAppSDKSelfContained`) has in its own folder: its manifest maps the APIs’ classes to them, `Microsoft.Windows.AI.Imaging.dll` and `Microsoft.Windows.AI.Text.dll` among them. With a stub of an API in the app’s `axx-models` folder, `models-windows` puts a library of its own in that file’s place as the app starts, and the app’s own back as the scenario ends: a class the folder has a stub of is the library’s, and any other the app’s file’s. The language model’s classes are the library’s together, with a stub of the model or of any of its skills: a skill takes the model it is of, whose availability the model’s stub gives, or a skill’s. axx builds the library once for the app, against the app’s own `.winmd` files, with C++/WinRT (its release, which axx downloads, pinned) and MSVC: Visual Studio’s or its Build Tools’ “Desktop development with C++”, with a Windows 11 SDK.
The library answers as Windows does, which answers only an app with package identity whose package declares the `systemAIModels` capability: an unpackaged app is refused (`UnauthorizedAccessException`), as Windows refuses it, with or without a stub. An app that is not packaged takes identity from a package of its build’s folder (packaging with external location), which its build registers, and Windows registers a package of no signature in Developer Mode only. A stub of a PC without the model gives its reason in the contract’s `format.details.availability`, in Windows’ words: `NotSupportedOnCurrentSystem` (`GetReadyState`’s answer, `EnsureReadyAsync` fails, `CreateAsync` throws, with the errors Windows gives), or `NotReady`, a model `EnsureReadyAsync` makes ready.
**What the app says.** An app’s speech is heard with the step `the {word} app's speaker plays the {string} file`: an utterance of the app’s has the file’s sound. The voice stub speaks a mapping’s recording for a text that has the mapping’s key, so the file is that recording, as the voice speaks it.
```gherkin
Scenario: The desk reads the handover rules aloud
Given the voice.onnx file in the desk-models folder is a copy of the models/handover-voice.onnx file
And the voice.onnx.json file in the desk-models folder is a copy of the models/handover-voice.onnx.json file
And the depot app is launched
And the "Handover" element is clicked in the depot app
When the "Read aloud" button is clicked in the depot app
Then within 20s the depot app's speaker plays the "recordings/handover-rules.wav" file
```
A pack answers the APIs its app’s folder has stubs of; the app’s other calls reach the platform as they would. A folder has one stub of an API; a stub that cannot be read, or a second of an API, stops the app’s start (`AXX-E0704`).
**A device without the model.** A stub of `ios.foundation-models` (or `macos.foundation-models`) can be an iPhone (or a Mac) without Apple Intelligence: its contract’s `format.details.availability` gives the reason, in Apple’s words (`deviceNotEligible`, `appleIntelligenceNotEnabled` or `modelNotReady`), and `SystemLanguageModel`’s `availability` and `isAvailable` give it. A stub of `ios.private-cloud-compute` can say Apple’s server model is not there (`deviceNotEligible` or `systemNotReady`), as `PrivateCloudComputeLanguageModel`’s `availability` does. Its mappings are a default only, which the app, told the model is not there, does not ask. An iOS Simulator otherwise has the Mac’s model when the Mac has Apple Intelligence, so a scenario of an iPhone without it says so:
models/no-apple-intelligence-ios/contract.json
```json
{
"format": {"type": "platform", "details": {"api": "ios.foundation-models", "availability": "deviceNotEligible"}},
"task": "text-generation"
}
```
The Android emulator has no Gemini Nano: a scenario without its stub is a phone without it.
**Speech.** The app’s microphone hears a recording when a step says so: it waits until the app listens, and the app’s stubbed recognizer answers its mapping.
```gherkin
Scenario: A recipient says their instructions
Given a seeds/portal-instructions-dictated.yaml db seed
And the "/track/PX-WEB-5704" page is opened
When the "Dictate" button is clicked
And the parcels app's microphone hears the "recordings/behind-the-gate.wav" file
Then the "Instructions for your courier" field has the value "Behind the green gate, please."
```
The step matches the recording to the speech stub’s mappings as the stub’s keys were made (resampled to its rate), and the recognizer answers it as a final result, with confidence 1; partial results and timings are not played. In a browser, the page’s own microphone (`getUserMedia`) records it too, then silence: a page that records its user for a model of its own (a Whisper it runs itself) hears it with no speech stub, and a `SpeechRecognition` started on that microphone’s track answers its mapping. The page hears it through Web Audio, which Firefox runs on Linux only with a sound server: on a CI machine with none, start PulseAudio with a sink that plays to nothing (`pulseaudio --start`, then `pactl load-module module-null-sink`). On a phone, the app’s own capture hears it, then silence: on iOS, `AVAudioEngine`’s input taps, `AVAudioRecorder`’s files and `AVCaptureSession`’s audio outputs; on Android, `AudioRecord` (converted to its rate, channels and format) and `MediaRecorder`‘s files. An app that runs a sound model of its own on its microphone hears the recording with no speech stub: the couriers’ apps hear the doorbell or a knock when no one is home, with the app’s own sound classifier (Sound Analysis on iOS, MediaPipe on Android).
**What each pack answers**, and what it does not:
* **iOS.** Vision’s requests through image and sequence request handlers (the newer Swift Vision request types are not answered). Text recognition answers one observation, of the whole picture. Detections’ boxes are shares of the picture; a segmentation’s mask is the contract’s size. A recognition of a recording, a URL or PCM buffers (non-interleaved floats); `CMSampleBuffer` speech input fails, named. Physical devices and distribution builds are not answered.
* **Android.** ML Kit’s vision clients of a `Bitmap` (`InputImage.fromBitmap`; other sources fail, named), and the Prompt API of ML Kit (not every Google AI SDK). Text recognition answers one block of the whole picture; detections have no tracking IDs. ML Kit’s clients of the app’s own model (custom image labeling) run its model, as any app’s model: stub that model instead.
* **Language models.** Text responses, and a stream’s answer in one piece. Pictures in prompts, tools, guided generation and token counts are not played.
* **Browsers.** The app’s origin, its frames of that origin and its popups. Workers and frames of other origins are not answered.
Natural Language’s `NLModel` runs the app’s own model, but only a text classifier or word tagger that Create ML made, whose parameters are in Create ML’s own format, which Apple does not document: axx writes no stubs of them. An app that runs a text model with Core ML itself (a BERT’s or MiniLM’s, converted) is stubbed as any Core ML model.
## How a stub answers
[Section titled “How a stub answers”](#how-a-stub-answers)
A stub’s graph reduces its input as matching does, scores it against each mapping’s input and against the similarity threshold, and gives the outputs of the best:
| Format | Its graph |
| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ONNX | Standard operators (`AveragePool`, `MatMul`, `ArgMax`, `Gather`…), at the real model’s operator set, from 7 on. A language model’s is a decoder of the real one’s inputs and outputs, from operator set 13 on: each call writes its tokens in the cache’s tensors that grow, and who wrote them (a prompt, or the model), after the conversation’s positions the attention mask says it has (a cache that grows each call, as transformers.js keeps it, or a buffer of the conversation’s greatest length, as ONNX Runtime GenAI does); the others it gives back as they were (LFM2’s convolutions’ state). It finds the first mapping whose text the conversation has before the prompt’s end, and gives its answer’s next token, as far along the answer as the call is past the prompt. Tokens are kept in halves, so a cache of half-precision floats holds them exactly. A speech recognizer’s is two: its encoder matches the sound’s features to the mappings’ and gives the mapping in its hidden state; its decoder writes that mapping’s transcript after the tokens the runtime forces (its language, its task), then its end. |
| TFLite | Builtin operators at their first version, which every LiteRT has (`AVERAGE_POOL_2D`, `FULLY_CONNECTED`, `ARG_MAX`, `GATHER`…). |
| ExecuTorch | The ONNX stub’s graph, each operator one or more calls of ExecuTorch’s portable kernels (`avg_pool2d`, `mm`, `argmax`, `index_select`…), its tensors’ memory planned, of the contract’s fixed shapes. A language model’s is the real one’s methods of settings, and a forward of portable kernels’ calls whose planned memory keeps the conversation’s tokens, and who wrote each (a prompt, or the model: the token the call before gave, at the position after it), from call to call; it finds the first mapping whose text the conversation has before the prompt’s end, and gives its answer’s next token. A speech recognizer’s `encode` pads the sound with silence, computes Whisper’s log-mel features of it (as the app’s runtime would, in portable kernels) and matches them to the mappings’ as an ONNX recognizer’s encoder does; its `decode` writes the mapping’s transcript after the tokens the app forces, a call each. An app built with only its real model’s kernels (ExecuTorch’s selective build) lacks the stub’s: `axx models build -v` names them. |
| TensorFlow SavedModel | The ONNX stub’s graph, each operator as TensorFlow’s (`AvgPool`, `BatchMatMulV2`, `ArgMax`, `GatherV2`…), a graph of constants and no variables, with the real model’s tags and signature, its inputs and outputs the real model’s tensors’ names (outputs of one node an `IdentityN` of that name: `StatefulPartitionedCall:0`, `:1`…). It is one file, `saved_model.pb`: TF Serving and `tf.saved_model.load` load the folder it is in. |
| TensorFlow\.js | The SavedModel stub’s graph, its integers int32s, its constants in one shard beside `model.json` (`group1-shard1of1.bin`, which `axx models build -o model.json` writes), with the real model’s signature; of a model of no signature, none, its placeholders and outputs the real model’s nodes’ names, and no other node one no node takes. |
| Keras | A Functional model of the layers every Keras has (`AveragePooling2D`, `LayerNormalization`, `Dense`, `GlobalMaxPooling1D`, `ReLU`…), its weights in HDF5: the input reduced as matching reduces it, its correlation with each key, the best’s one-hot, and the answers’ table’s product with it; a text’s tokens found by an `Embedding` and a causal `Conv1D`; a sound’s log by Keras 3’s `STFTSpectrogram` (of one sample a frame) or Keras 2’s `tf.math.log`. It is the file the real model is, of its version of Keras: a `.keras` file of Keras 3, or a `.h5` file of Keras 3 or Keras 2 (tf.keras, tf\_keras), which `load_model` loads. No layer runs Python code (`Lambda`). |
| TorchScript | The ONNX stub’s graph as a frozen module’s code (`torch.avg_pool2d`, `torch.matmul`, `torch.argmax`, `torch.index_select`…), which TorchScript compiles when it loads it, its tables the module’s constants, of the real module’s class. `torch.jit.load`, LibTorch and Triton’s PyTorch backend load it. |
| PyTorch Mobile | The TorchScript stub’s module, and its forward’s bytecode (version 8), which the lite interpreter runs without compiling code: each call’s arguments loaded from registers and constants, then its operator (`aten::avg_pool2d`, `aten::matmul`, `aten::argmax`, `aten::index_select`…). `axx models build -v` names the operators the real model’s bytecode does not call, which a selective build lacks. |
| MNN | The ONNX stub’s graph, each operator as MNN’s converter translates an ONNX model’s (`BinaryOp`, `MatMul`, `ArgMax`, `GatherV2`…; its integers int32s, its pooling of MNN’s packed layout). MNN’s CPUs of ARM compute in half precision at its normal precision: a stub’s values come out rounded to half precision there, its answers the same; at high precision, as they are. |
| ncnn | Its own layers, which every ncnn build registers (`Pooling`, `InnerProduct`, `Reduction`, `BinaryOp`, `Clip`…): the input reduced as matching reduces it (a sound’s spectrum by `InnerProduct`s of its frames), its correlation with each key, the best’s one-hot (`Reduction`‘s greatest, and `Clip`), and the answers’ table’s product with it, its weights in the `.bin` beside the `.param` (`axx models build -o model.param` writes `model.bin`). ncnn has no comparisons of tokens: its stubs match pictures, sounds and tensors. ncnn’s CPUs of ARM compute in half precision by default (`use_fp16_arithmetic`): a stub’s values come out rounded to half precision there, its answers the same. |
| OpenVINO | The ONNX stub’s graph, each operator as OpenVINO’s operation sets have it (`AvgPool`, `MatMul`, `TopK`, `Gather`…), its weights in the `.bin` beside the XML (`axx models build -o model.xml` writes `model.bin`). OpenVINO’s CPUs of ARM compute in half precision unless the app asks for full (`INFERENCE_PRECISION_HINT`): a stub’s values come out rounded to half precision there, its answers the same. |
| Core ML (a detector’s pipeline) | A network that gives each answer’s objects as its first candidates (their boxes’ centers and sizes, their labels’ scores), the rest scoring nothing, then the real model’s non-maximum suppression, which keeps them: Vision recognizes the answer’s objects (`VNRecognizedObjectObservation`), labels, confidences and boxes. An answer whose objects overlap more than the suppression keeps stops the build, named. |
| Core ML | A neural network of the layers every Core ML has had (pooling, mean-variance normalization, inner product, argmax, embedding), at the real model’s specification version: a classifier’s label and probabilities are Core ML’s, from its class labels. A text encoder’s is of Core ML 3’s N-D layers (`equal`, `convolution`, `reduceMax`, `batchedMatmul`, `argMax`, `embeddingND`), of its inputs’ shapes: its ids compared with each token of the mappings’ texts, each text’s tokens found one after another, its last word’s end after them; its answers exact on the CPU, the GPU and the Neural Engine. |
| LiteRT-LM | A TFLite model of the real model’s prefill and decode signatures, whose KV cache holds the conversation’s tokens and who wrote them: decode finds the first mapping whose text the prompt has, and gives its answer’s next token. Its LLM metadata and tokenizer are the real model’s: a stub of a Gemma 3 model is its tokenizer (4.6 MB) and 54 KB. |
| MNN-LLM | The GGUF stub’s transformer as MNN’s ops, a decoder around MNN’s `Attention` op (which keeps the KV cache, of the engine’s): of the embeddings the engine looks up itself (`embeddings_bf16.bin`, narrow: the transformer’s first matrix widens them), the positions’ rotations computed in the graph, and the logits of the tokens the stub writes, gathered into the vocabulary’s. Its `llm_config.json` is the real one’s (its chat template, its sampler) of the stub’s sizes; the real `config.json` and tokenizer go beside it. Its residual stream is scaled so that MNN’s low precision, in halves, keeps it: a stub answers alike on the CPU, at low and high precision, and on Metal, of turns on their own (MNN’s default) or kept (`reuse_kv`). |
| MLC (WebLLM) | The GGUF stub’s transformer laid out in the real model’s own architecture, which its library is compiled for: its width, layers, heads and the key-value heads they share, its MLP’s size, its rotary angles (Llama 3’s scaling, longrope’s short factors, the head dimensions Phi-4 leaves still), its stages packed onto the first of its layers and the rest of no weights. Each matrix is written in the real model’s records and quantization: of `q4f16_1`‘s groups of 32, each group’s scale one whose 4-bit multiples are its values exactly (a stub’s groups have few values, of whole multiples of each other); its norms of a ballast whose RMS is a power of two, exact in halves and bfloat16s. Qwen 3’s heads’ norms keep each head’s query and key: a content head has a key-value head of its own (or, of too few, they share them, each key of a sink its norm divides by). Gemma’s embeddings are the stub’s ÷ √E, which Gemma multiplies them by; Gemma 2’s and 3’s norms of each sublayer’s output keep its features, of a sink each layer adds to its output and the norm drops; Gemma 2’s cap of its logits keeps the next token’s lead, of codes that share a dimension at most; Gemma 3’s content heads are of its global layers, the others of a window. OLMo 2’s attention and MLP read the residual stream as it is (their weights of the ballast ÷ its value), and its queries’ and keys’ norms are of the whole projection’s. A LayerNorm’s (StableLM’s, GPT-NeoX’s, Phi-2’s) takes the stream’s mean from it first: a stub’s stream sums to 0 at every position, of a ballast of b and of −b, every embedding’s and every head’s and unit’s output balanced in dimensions of their own, so that the LayerNorm is an RMS norm. Phi-2’s attention and MLP read the stream alike, as the layer began: a stage’s units are of the layer after its heads’. Qwen 3.5’s heads are of its layers of full attention (every fourth), each head’s output’s gate 1, and its layers of linear attention (Gated DeltaNet) of no weights add nothing; Phi-3.5 vision’s encoder is of none, an image’s tokens of the ballast alone, of no code. A content head of a pair that turns more than a third of a circle over the context scores its offsets of phases: a head of each third, which the presence sums. A stub of SmolLM2-135M is 74 MB, as the real one’s quantization makes it; of Mistral 7B, 3.9 GB. WebLLM answers its chats as the stub says, greedily and sampled, on a GPU (a machine of none, whose WebGPU is SwiftShader’s, runs it too slowly to test). |
| Foundation Models | A Swift type of the `LanguageModel` protocol, and its executor: it reads the session’s conversation (the text of its instructions, prompts, tools’ outputs and responses), finds the first mapping whose text it has, as words, and answers that mapping’s text (or its JSON, which a session asked for a generable type reads as one) in one piece. Its capabilities are the contract’s. |
| safetensors | The GGUF stub’s transformer as a `LlamaForCausalLM`: its weights in `model.safetensors`, and the `config.json` of them beside it (`axx models build -o /model.safetensors` writes both, in place of the real model’s), its rotary positions llama.cpp’s (`longrope`‘s factors). The real model’s tokenizer and generation config stay. Its config says `float32`: an app that loads it in `bfloat16` rounds its values, and its answers. A text embedding model’s is the GGUF embedding stub’s transformer as a `Gemma3TextModel` of bidirectional attention (`use_bidirectional_attention`, which transformers has from 4.56), of the real model’s hidden size: every token’s output is the text’s embedding, so sentence-transformers’ pooling and normalization (the real model’s modules) and an app’s own pooling give it. A model’s Dense modules are written too (each module’s `model.safetensors` and `config.json`, in its folder), of the features they take and give, which pass the embedding on as it is. |
| Whisper’s weights (safetensors, CTranslate2, whisper.cpp, OpenAI Whisper) | Whisper’s own architecture, of the real model’s sizes: its convolutions, transformer layers, LayerNorms and GELUs. The encoder sums each band’s mels, pools them to tenths of a second, scales each tenth to its own loudness (a LayerNorm), hears it or not (an attention’s softmax, of how loud it is), and takes each heard tenth’s dot products with the mappings’ keys; it takes their means, a key’s, and the share of heard tenths. The decoder’s cross-attention scores each key its mean’s square, and the default the threshold’s, of the share of heard tenths (so that no root is taken), and writes the best’s transcript after the tokens the runtime forces, its timestamps from 0 to the end of the last heard tenth. Every value fits half precision, and every choice has a margin over its rounding: whisper.cpp’s weights are of half precision (its convolutions take no other), as GPUs’ runs are. Each layer reads only what it needs, of values of one size, so that CTranslate2’s int8, which rounds each layer’s inputs to a 127th of their largest, keeps them. A stub is the real model’s size (its token embeddings are most of it): 75 MB of whisper.cpp’s tiny and of OpenAI’s, 148 MB of transformers’ in float32. |
| Parakeet | parakeet.cpp’s FastConformer of one layer, and a CTC head, with a frontend of its own (32 mel bands of the log of one plus their power, never below 0): the subsampling’s convolutions carry each band to the encoder, its attention averages the sound’s normalized frames, and its head scores the average’s direction against each mapping’s key, and the default at the threshold, and spells the nearest’s transcript (one token an answer). parakeet.cpp decodes it with CTC: a CTC model’s app has no other decoder, and a hybrid TDT-CTC model’s chooses it (`--decoder ctc`). It resamples a sound of another rate linearly, as the stub’s keys are. |
| GGUF | A transformer of llama’s architecture, of five layers, with the real model’s vocabulary, tokenizer and chat template. Its heads at fixed offsets give each position the tokens before it; where they hold a chunk of a mapping’s text, the position flags it; the prompt’s mapping is the first whose chunks it has all of; and the answer’s next token follows from its mapping and the tokens before it. Every weight and value is exact in half precision, so llama.cpp answers the same on the CPU and on GPUs. An embedding model’s is of the real one’s embedding size and pooling, its attention the whole text’s, so every token’s output is the text’s embedding, whatever the app pools: its norms’ epsilon (2^40) outweighs every value’s square, so each is linear and exact; its last layer takes every feature out (exactly: each step’s 1 less 1, each head at an offset again, negated) and adds the mapping’s embedding, each value a sum of three halves. A WordPiece model’s keys are whole words: the token after a key’s last begins another. A reranker’s is the embedding stub’s transformer as a NeoBERT (llama’s transformer, of the architectures llama.cpp gives a classifier and no softmax), its classifier the sum of its mapping’s score as three halves: llama.cpp’s server answers its logit, node-llama-cpp’s ranking its sigmoid. A Qwen3 reranker’s is that transformer as a Qwen3 (its attention causal, so its last token, which llama.cpp pools, has the whole text; its heads wider, every pair of them rotated as Qwen3 rotates them, its content’s pair too slowly to tell), its classifier’s yes that sum and its no 0: llama.cpp’s softmax of them is the mapping’s score. |
Every runtime of the format runs it but a minimal build made for one model alone (`axx models build -v` names the operators the real model does not use). Answers that end alike (a detector’s: its objects, then nothing for thousands of anchors) are stored to where they differ and padded (ONNX’s `Pad`, TFLite’s `PADV2`): a stub of MediaPipe’s EfficientDet-Lite0 (13.8 MB) is 766 KB, most of it the metadata it carries. A segmenter’s masks are stored as each pixel’s label (ONNX’s `OneHot`, TFLite’s `ONE_HOT`): a stub of DeepLab v3 (2.7 MB) is 207 KB. In ONNX, answers of at most 256 values (a mask of confidences: its gray levels) are stored a byte a value, with the values they take (`Gather`): the parcels portal’s stub of BiRefNet\_lite (115 MB), of two masks of 1024×1024, is 2 MB.
## Add a format, a task or a kind of input
[Section titled “Add a format, a task or a kind of input”](#add-a-format-a-task-or-a-kind-of-input)
The `models-core` pack’s formats, tasks and kinds of input are its own packages’ registrations, and a pack of yours adds its own the same way: a format of models axx does not write yet, the answers of a task it does not know, a kind of input a mapping keys on. List your pack in `axx-packs.yaml` beside `models-core`, and `axx models contract` and `axx models build` use what it registers. The four steps stay the same.
| Registry | What it takes | The models-core pack’s |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `format.Register` | A format: its type and models’ extensions, a reader of a model’s contract (or none: the contract is written by hand), a writer of a stub, as one file or a folder of files, a reader of a model’s operators when its contract needs more than the model (a language model’s tokenizer), and whether its models are an API’s, of no tensors (`API`: Foundation Models’). A format’s own settings go in the contract’s `format.details`. | ONNX, TFLite, Core ML, GGUF, LiteRT-LM, MNN-LLM, MLC (WebLLM), ExecuTorch, OpenVINO, TensorFlow SavedModel, TensorFlow\.js, Keras, safetensors, TorchScript, PyTorch Mobile, MNN, ncnn, CTranslate2, whisper.cpp, OpenAI Whisper, Parakeet, Tesseract, Piper, Foundation Models, and the platform APIs’ stubs (`.axxmodel`) |
| `task.Register` | A task: the keys of its answers (and which of them name files), and how an answer becomes the outputs’ values (or, for a model that generates text, the text), and what a contract of the task must give. | `classification`, `detection`, `segmentation`, `embedding`, `text-recognition`, `speech-to-text`, `text-generation`, `text-to-speech` |
| `stub.RegisterInput` | A kind of input: its key in a mapping’s `input`, and how a mapping’s value becomes the model’s input, which is matched to the nearest, or the text a prompt has. | `image`, `audio`, `tensor`, `text` |
steps/modelformats/rknn.go
```go
package modelformats
import (
"github.com/nimbusxr/axx/packs/models/core/format"
"github.com/nimbusxr/axx/packs/models/core/stub"
)
func init() {
format.Register(format.Format{
Type: "rknn", Name: "RKNN", Extensions: []string{".rknn"},
Contract: readContract, // the model's inputs and outputs, from its .rknn
Write: format.File(func(s *stub.Stub) ([]byte, error) {
if s.Nearest == nil {
return nil, format.Unmatched("RKNN", s)
}
// The graph of the nearest matcher: s.Nearest.Reducer reduces the input,
// s.Nearest.Table holds the keys, s.Answers the answers.
return graph(s), nil
}),
})
}
```
A format’s stubs answer as the stub does (`stub.Stub.Answer`, the reference): `modelstest.Conform` holds them to the cases every format of axx is held to, run in the format’s own runtime.
# Tags and filtering
> Select which scenarios run by path, line, tag expression or name, and set defaults in axx.yaml.
Every way of selecting scenarios combines: paths narrow the files, then tags and names filter the scenarios in them.
## By path and line
[Section titled “By path and line”](#by-path-and-line)
```sh
axx run # run.paths from axx.yaml (default: features)
axx run features/quotes # a feature's folder
axx run features/quotes/quotes.feature # one file
axx run features/quotes/quotes.feature:14 # the scenario on line 14
axx run features/a/a.feature:14 features/b/b.feature:9 # several
```
A line number selects the scenario that starts on that line or contains it, so the line of any step works. That is why the `rerun` command in a failure report is always a `file:line`.
## By tag
[Section titled “By tag”](#by-tag)
Tag features, rules, scenarios and examples:
```gherkin
@parcels
Feature: Register parcels
@smoke
Scenario: A shop lists its own parcels
Given a GET request to /api/parcels?sender=lark-ceramics
When the request is executed
Then the response status code is 200
@wip
Scenario: A cancelled parcel is gone
Given a DELETE request to /api/parcels/PX-TAG-0001
When the request is executed
Then the response status code is 204
```
Select with a [tag expression](https://cucumber.io/docs/cucumber/api/#tag-expressions):
```sh
axx run --tags @smoke
axx run --tags "@parcels and not @wip"
axx run -t "(@smoke or @critical) and not @slow"
```
Tags are inherited: a scenario has its own tags plus those of its feature, rule and examples.
## By name
[Section titled “By name”](#by-name)
```sh
axx run --name "registered once" # a regular expression on the scenario name
axx run -n "^A shop" -n "cancelled" # repeatable: matches either
```
## A shard of a run
[Section titled “A shard of a run”](#a-shard-of-a-run)
`--shard i/n` runs a share of what the rest selects, for runners that share a run: every nth scenario, in their files’ and lines’ order, from the ith. `1/n` to `n/n` run each scenario once between them, the same on every machine ([Share a run between runners](/next/guides/run-in-ci/#share-a-run-between-runners)):
```sh
axx run --tags @android --shard 1/2 # one runner
axx run --tags @android --shard 2/2 # the other
```
## Defaults in axx.yaml
[Section titled “Defaults in axx.yaml”](#defaults-in-axxyaml)
axx.yaml
```yaml
run:
paths: [features]
tags: "not @wip and not @ignore"
```
`--tags` on the command line replaces `run.tags`.
`run.tags` narrows whole runs: `axx run`, and the directories you name. A feature file you name, with or without a line (`axx run features/checkout.feature:14`), runs the scenarios it selects whatever their tags, so a scenario that `run.tags` leaves out of a whole run still runs when you point at it, from the command line or your editor. `--tags` applies to named files too.
A line that is in no scenario of its file, like the feature’s description or its Background, is an error (`AXX-E0204`) rather than a run of nothing.
## Select by what scenarios use
[Section titled “Select by what scenarios use”](#select-by-what-scenarios-use)
`run.uses` keeps the scenarios that use a step of one of its packs, with no tags: a scenario whose steps drive an iOS app uses `mobile-ios`, and axx knows it from the steps. A profile per platform puts it to work:
axx.yaml
```yaml
profiles:
web: {run: {uses: [web-core]}}
ios: {run: {uses: [mobile-ios]}}
android: {run: {uses: [mobile-android]}}
watch: {run: {watch: true, slowdown: 500ms}}
```
```sh
axx run # every scenario
axx run --profile ios # the scenarios that drive the iOS app
axx run --profile android,watch # the Android app's, watched
```
Like `run.tags`, it narrows whole runs: a feature file you name, or click in your editor, runs whatever it leaves out. Profiles merge in order, so `android,watch` takes `uses` from `android` and watching from `watch`.
## Tags that change behavior
[Section titled “Tags that change behavior”](#tags-that-change-behavior)
* Tags listed in `run.exclusive` make scenarios run alone, after the parallel phase ([Run in parallel](/next/guides/parallel-runs/)).
* With `active.enabled`, tags decide which services start ([Manage the services under test](/next/guides/manage-services/#start-only-what-a-run-needs)).
## Rerun what failed
[Section titled “Rerun what failed”](#rerun-what-failed)
```sh
axx run --rerun-file build/axx/rerun.txt # writes file:line of each failed scenario
axx run $(cat build/axx/rerun.txt) # run only those
```
## Preview a selection
[Section titled “Preview a selection”](#preview-a-selection)
`--dry-run` matches every selected step without starting services or executing anything, which shows what a filter selects:
```sh
axx run --tags @smoke --dry-run
```
# Test A2A agents
> Send messages to your A2A agent as another agent does, over JSON-RPC, HTTP+JSON or gRPC; check its card, its tasks' states and artifacts, and what it streams; and mock the agents your service asks.
An A2A agent (Agent2Agent protocol) is an agent other agents ask for work. It publishes a card that says what it does and how to reach it, and it answers a message with a reply or a task: work that goes through states (working, input required, completed…) and ends with artifacts. The `a2a` pack tests your agent by doing what another agent does.
```gherkin
Scenario: A hold asks until which day, and holds the parcel on the reply
Given a seeds/a2a-hold.yaml db seed
And the parcels a2a agent with the following properties:
| url | http://localhost:8400 |
When a message is sent to the parcels a2a agent:
"""
Please hold PX-A2A-9202 at its depot.
"""
Then the parcels a2a agent's task is input-required
And the parcels a2a agent's answer contains 'Until which day should PX-A2A-9202 be held?'
When a reply is sent to the parcels a2a agent:
"""
2026-10-05
"""
Then the parcels a2a agent's task is completed
```
Add the pack to the project with `axx pack add a2a` ([Choose packs](/next/guides/use-packs/)).
## Register the agent
[Section titled “Register the agent”](#register-the-agent)
| Property | What it is |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `url` | The agent’s URL. Its card is read at `/.well-known/agent-card.json` on its origin. |
| `card` | The agent’s card, when it is not there: a file of the project, or a URL. |
| `transport` | How to reach the agent: `JSONRPC`, `HTTP+JSON` or `GRPC`, among the interfaces its card lists. Default: the card’s first. |
| `header.` | A header sent with every request, such as `header.Authorization`. |
| `timeout` | How long a call may take. Default `30s`. |
| `push url`, `push token` | Where the agent sends its tasks’ updates (a webhook: a WireMock the mock pack checks), and the token it sends with them. |
The card is checked for what A2A 1.0 requires: its name, description, version, interfaces and input and output modes. Check what it says, and its skills:
```gherkin
Then the parcels a2a agent's card has the following properties:
| name | Parcels agent |
| capabilities.streaming | true |
And the parcels a2a agent has the track-parcel skill
```
## Send messages
[Section titled “Send messages”](#send-messages)
```gherkin
When a message is sent to the parcels a2a agent:
"""
Where is PX-A2A-9201?
"""
When the a2a/hold-request.json message is sent to the parcels a2a agent
When a reply is sent to the parcels a2a agent:
"""
2026-10-05
"""
When the parcels a2a agent is asked to cancel its task
```
* **A message starts a conversation,** and waits for the agent’s answer: a reply, or a task that ended or waits for input.
* **A reply continues the agent’s last task.** That is how a task that asks for input (`input-required`) gets it.
* **A file’s message** is an A2A message in JSON, with text, data or file parts.
## Check the answer
[Section titled “Check the answer”](#check-the-answer)
```gherkin
Then the parcels a2a agent's task is completed
And the parcels a2a agent's answer contains 'PX-A2A-9201 is in transit'
And the parcels a2a agent's task has an artifact where:
| name | parcel-status |
| data.status | IN_TRANSIT |
```
* **States are written as people say them:** `submitted`, `working`, `completed`, `input-required`, `auth-required`, `failed`, `canceled` and `rejected`. A check waits for a task still at work (10 seconds, or `within {duration}`), and fails at once for a task in another state that will not change without a message.
* **The answer** is the agent’s reply, or its task’s status message and the text of its artifacts.
* **An artifact’s properties** are its `name`, `description`, `text` (its text parts), `data` (its first data part: `data.status`) and `parts`.
## Streams
[Section titled “Streams”](#streams)
```gherkin
When a message is streamed to the parcels a2a agent:
"""
Where is PX-A2A-9204?
"""
Then the parcels a2a agent's stream received an update where:
| kind | status |
| state | working |
| message | Looking up PX-A2A-9204 |
And within 10s the parcels a2a agent's task is completed
```
A streamed message takes the agent’s answer as it comes: a status update has `kind` `status`, its `state` and its `message`; an artifact update has `kind` `artifact` and its `artifact` (`artifact.name`, `artifact.data.status`…). The checks wait for what the stream has not sent yet, and fail at once when it has ended. **A stream belongs to the scenario,** which ends it when it ends.
## Mock the agents your service asks
[Section titled “Mock the agents your service asks”](#mock-the-agents-your-service-asks)
When your service asks another team’s agent, the Axx WireMock image mocks it from its card, with a mapping file per answer, and the mock pack checks what your service asked it. See [Mock MCP servers and A2A agents](/next/guides/mock-dependencies/#mock-mcp-servers-and-a2a-agents).
See the [a2a pack’s reference](/next/references/packs/a2a/) for every step.
# Test AI features
> Mock the models your service asks with the Axx WireMock image, in the format of whichever provider's API it speaks, and check what your service asked them: the prompt, the tools, the schema, and what it must never send.
A service that asks a model is tested like a service that calls any other dependency. The model is a mock, its answers are stubs, and the scenarios check what the service does with each answer and what it asked. A live model answers differently from run to run, and it will not produce the answers that break AI features on demand: a malformed answer, a rate limit, a stream that stops halfway.
The Axx WireMock image’s model mock answers in the format of the request, whichever provider’s API your service speaks:
```gherkin
Scenario: The assistant looks the parcel up before it answers, and never tells the model the street
Given a seeds/assistant-parcel.yaml db seed
And a POST request to /api/assistant/questions
And a request payload using an application/json content example
And the request payload property question is 'Where is my parcel PX-AI-8102?'
When the request is executed
Then the response status code is 200
And the response payload property answer is 'Your parcel PX-AI-8102 is out for delivery in Leipzig and arrives today.'
And the mocked models model was offered the track_parcel tool in the request about 'PX-AI-8102'
And the mocked models model's request about 'PX-AI-8102' contains 'OUT_FOR_DELIVERY'
And the mocked models model's request about 'PX-AI-8102' does not contain 'Lindenweg 14'
```
## Point your service at the mock
[Section titled “Point your service at the mock”](#point-your-service-at-the-mock)
Run the image as the model, and give your service its URL as the model’s base URL: every SDK takes one.
infra/compose.yaml (excerpt)
```yaml
models:
image: ghcr.io/nimbusxr/axx-wiremock: # a version with model mocks
ports:
- '8086:8080'
volumes:
- './models:/home/wiremock'
app:
environment:
PARCELS_MODEL_URL: http://models:8080/v1
```
Nothing says which provider the mock is: it answers each request in its own API’s format.
| Your service speaks | Its base URL | The mock answers |
| ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |
| OpenAI’s API, and the servers that speak it (Azure OpenAI, vLLM, llama.cpp, LM Studio, Ollama’s `/v1`, Mistral, DeepSeek, OpenRouter, LiteLLM…) | `http://models:8080/v1` | Chat Completions, Responses, embeddings, models |
| Anthropic’s API, and the servers that speak it | `http://models:8080` | Messages and models; Claude on Bedrock (`invoke`) and on Vertex AI (`rawPredict`) |
| Gemini, on Google AI Studio or Vertex AI | `http://models:8080` | `generateContent`, `embedContent`, `batchEmbedContents`, models |
| Amazon Bedrock | `http://models:8080` (the SDK’s endpoint override) | Converse and ConverseStream; Titan and Cohere embeddings (`invoke`) |
| Ollama’s own API | `http://models:8080` | `/api/chat`, `/api/generate`, `/api/embed`, `/api/embeddings`, `/api/tags` |
The answers are checked against the providers’ own contracts: their official SDKs (OpenAI’s, Anthropic’s, Google’s and AWS’s) read them in the image’s tests, and OpenAI’s and Ollama’s published OpenAPI documents validate them.
## Write the answers
[Section titled “Write the answers”](#write-the-answers)
An answer is a WireMock mapping file. The `model-request` matcher says which requests the stub answers, and the `model-answer` transformer renders the stub’s answer, written the same way for every provider:
infra/models/mappings/address-note.json
```json
{
"name": "The address in Mara Lindqvist's note",
"request": {
"customMatcher": {"name": "model-request", "parameters": {"about": "Birkenallee 3"}}
},
"response": {
"transformers": ["model-answer"],
"jsonBody": {
"json": {"name": "Mara Lindqvist", "street": "Birkenallee 3", "postcode": "01067", "city": "Dresden", "country": "DE"}
}
}
}
```
### Which requests a stub answers
[Section titled “Which requests a stub answers”](#which-requests-a-stub-answers)
The `model-request` matcher takes:
| Parameter | Matches |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `about` | A text, or a list of texts, that the conversation has: in a message, the system prompt, or a tool’s call or result. JSON-escaped text counts (`Hauptstraße` has `Hauptstraße`). For embeddings, the texts to embed. |
| `afterTool` | The tool whose result the model was given last, since the user last wrote. A stub without it answers only when the model has had no tool result since. |
| `endpoint` | `chat` (the default), `embeddings`, or `models`. |
* **Keep scenarios apart** with `about`: name data that only one scenario has, like its parcel’s reference or its recipient’s street.
* **When stubs overlap,** WireMock’s `priority` decides, as for any stub.
* **A mistake in a stub stops WireMock** at startup, and names the stub: an unknown key, or a wrong value.
### What the model answers
[Section titled “What the model answers”](#what-the-model-answers)
| Key | The answer |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text` | Its text. |
| `json` | Its structured output: a JSON value, which the answer sends as its text. |
| `toolCalls` | The tools it calls: a list of `name`, `arguments` (an object), and optionally `id`. |
| `reasoning` | Its reasoning, as the provider shows it: OpenAI Responses’ reasoning summary, Anthropic’s thinking, Gemini’s thoughts, Bedrock’s reasoning content, Ollama’s thinking. For the servers that speak OpenAI’s Chat Completions API, `reasoning_content` and `reasoning`. |
| `refusal` | A refusal, in the provider’s refusal field where it has one (OpenAI’s `refusal`, Anthropic’s `refusal` stop reason), and as its text elsewhere. |
| `stop` | Why it stopped early: `length` (at its token limit) or `safety` (blocked). |
| `usage` | The tokens it reports: `inputTokens`, `outputTokens`, `reasoningTokens`. Estimated from the text when not given. |
| `embedding`, `dimensions` | For embeddings: the vector every text gets, or the dimensions of the vectors. |
| `models` | For models: the names of the models the provider lists. |
A tool loop is a stub per turn: the model calls the tool, then answers once it has the tool’s result. A mapping file can hold both:
infra/models/mappings/where-is-px-ai-8102.json
```json
{
"mappings": [
{
"request": {"customMatcher": {"name": "model-request", "parameters": {"about": "PX-AI-8102"}}},
"response": {
"transformers": ["model-answer"],
"jsonBody": {"toolCalls": [{"name": "track_parcel", "arguments": {"reference": "PX-AI-8102"}}]}
}
},
{
"request": {"customMatcher": {"name": "model-request", "parameters": {"about": "PX-AI-8102", "afterTool": "track_parcel"}}},
"response": {
"transformers": ["model-answer"],
"jsonBody": {"text": "Your parcel PX-AI-8102 is out for delivery in Leipzig and arrives today."}
}
}
]
}
```
* **Embeddings are deterministic:** each text gets a unit vector made from the SHA-256 of the text, so the same text always gets the same vector. It has as many dimensions as the request asks for, or else the stub’s `dimensions`, or else the provider’s usual number.
* **The mock remembers its Responses:** a request to OpenAI’s Responses API that continues an earlier response (`previous_response_id`) is read with that response’s conversation.
### Streams
[Section titled “Streams”](#streams)
The mock streams whenever the request asks for a stream, in the provider’s own encoding:
| API | Its stream |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat Completions (`"stream": true`) | Server-sent chunks, ending with `data: [DONE]`. With `stream_options.include_usage`, the usage comes in a chunk of its own, before it. |
| OpenAI Responses (`"stream": true`) | Typed server-sent events, from `response.created` to `response.completed`. |
| Anthropic (`"stream": true`) | Server-sent events, from `message_start` to `message_stop`, with a `ping`. On Bedrock, in AWS’s event stream. |
| Gemini (`streamGenerateContent`) | Server-sent events with `alt=sse`, a JSON array without. |
| Bedrock (`converse-stream`) | AWS’s binary event stream, each frame with its CRCs. |
| Ollama (unless `"stream": false`) | Newline-delimited JSON, ending with `"done": true`. |
### Failures
[Section titled “Failures”](#failures)
| Key | The failure |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `error` | The provider’s error, in its own shape and status: `type` is `rate_limit`, `overloaded`, `context_length`, `auth` or `server`. `message` replaces the provider’s usual message, and `retryAfter` is the seconds to wait (a `Retry-After` header, or Gemini’s `RetryInfo`; Bedrock, like AWS, sends none). With `afterEvents`, a stream sends that many events first, then the error, as the provider sends it in a stream. |
| `cutOffAfter` | A stream ends after that many events, without its end. |
| `malformed` | The answer breaks off in the middle of its JSON. |
| `type` | OpenAI | Anthropic | Gemini | Bedrock | Ollama |
| ---------------- | ----------------------------- | --------------------------- | ---------------------------------------------------------- | --------------------------------- | ------ |
| `rate_limit` | 429 `rate_limit_exceeded` | 429 `rate_limit_error` | 429 `RESOURCE_EXHAUSTED` | 429 `ThrottlingException` | 429 |
| `overloaded` | 503 `server_error` | 529 `overloaded_error` | 503 `UNAVAILABLE` | 503 `ServiceUnavailableException` | 503 |
| `context_length` | 400 `context_length_exceeded` | 400 `invalid_request_error` | 400 `INVALID_ARGUMENT` | 400 `ValidationException` | 400 |
| `auth` | 401 `invalid_api_key` | 401 `authentication_error` | 400 `API_KEY_INVALID` (401 `UNAUTHENTICATED` on Vertex AI) | 403 `UnrecognizedClientException` | 401 |
| `server` | 500 `server_error` | 500 `api_error` | 500 `INTERNAL` | 500 `InternalServerException` | 500 |
A slow model is WireMock’s own: `fixedDelayMilliseconds` delays the answer, and `chunkedDribbleDelay` sends it slowly, in chunks.
infra/models/mappings/busy.json
```json
{
"request": {"customMatcher": {"name": "model-request", "parameters": {"about": "PX-AI-8104"}}},
"response": {
"transformers": ["model-answer"],
"jsonBody": {"error": {"type": "rate_limit", "retryAfter": 1}}
}
}
```
## Check what your service asked
[Section titled “Check what your service asked”](#check-what-your-service-asked)
Register the mock as any mocked service, then check the requests the model got. A request is about a text when its conversation has it, as the `about` of a stub reads it.
```gherkin
Given the mocked models service with the following properties:
| url | http://${sys:local.host}:8086 |
Then the mocked models model was asked about 'PX-AI-8104' 2 times
And the mocked models model was offered the track_parcel tool in the request about 'PX-AI-8102'
And the mocked models model's request about 'PX-AI-8102' contains 'OUT_FOR_DELIVERY'
And the mocked models model's request about 'PX-AI-8102' does not contain 'Lindenweg 14'
And the mocked models model was asked for the schemas/recipient-address.json schema in the request about 'Birkenallee 3'
```
* **`contains` and `does not contain`** read everything the request sent: its messages, its tools’ results, its tools and its settings, JSON-escaped text included. A service that must never send a recipient’s street to a model proves it with `does not contain`; the check fails when the model was never asked about the text at all.
* **The schema** is a JSON Schema file of the project (JSON or YAML), compared with the structured output the request asks for, whatever the order of its keys.
* **Every request since the run started counts,** other scenarios’ too: name the scenario’s own data.
The mock pack’s other steps still reach any request by its path and payload. See the [mock pack’s reference](/next/references/packs/mock/) for every step.
## Record real answers
[Section titled “Record real answers”](#record-real-answers)
To see what a real model answers, run the image as a recording proxy in front of the provider, once, and point your service at it with a real key:
```sh
docker run --rm -p 8086:8080 -v "$PWD/recordings:/home/wiremock" \
ghcr.io/nimbusxr/axx-wiremock: \
--proxy-all=https://api.openai.com --record-mappings
```
WireMock writes a mapping file of each exchange in `recordings/mappings`, in the provider’s own format, and the answers’ bodies in `recordings/__files`. A recorded mapping answers only the very request it recorded, as it was answered; review it, and never commit a key. To keep the scenarios readable, turn what the model answered into `model-answer` stubs: its text, its tool calls, its structured output.
Recordings test your service against answers a model once gave. Whether a model still answers your prompts well is a different question, for evaluations run against the model itself.
# Test cloud services
> Check what your services do with S3, SQS, SNS, EventBridge, DynamoDB, Cloud Storage, Pub/Sub, BigQuery, Firestore, Blob Storage and Service Bus, against a cloud account or local emulators.
Services often do their work through the cloud: a file lands in a bucket and a job picks it up, results go to a warehouse, and events go to a topic. The cloud packs let a scenario do what the outside world does, such as uploading the file or publishing the event. The scenario then checks what your service did: the row, the object, the message.
```gherkin
Scenario: A claim within the limit is settled when its photo arrives
When the evidence/crushed-box.png file is uploaded to the claim-evidence s3 bucket as claims/CLM-4101/crushed-box.png
Then within 30s the claims dynamodb table has an item where:
| id | CLM-4101 |
| status | APPROVED |
And the refund-requests sqs queue has a message where:
| claim | CLM-4101 |
| attribute reason | DAMAGED |
```
## The packs
[Section titled “The packs”](#the-packs)
| Cloud | Packs |
| ------------ | -------------------------------------------------------------------------------- |
| AWS | `aws-s3`, `aws-sqs`, `aws-sns`, `aws-eventbridge`, `aws-dynamodb`, on `aws-core` |
| Google Cloud | `gcp-storage`, `gcp-pubsub`, `gcp-bigquery`, `gcp-firestore`, on `gcp-core` |
| Azure | `azure-blob`, `azure-servicebus` |
Each pack talks to its service through the cloud’s official SDK. The [pack reference](/next/references/packs/) has a page for each one, with its steps and settings, grouped by cloud. List the packs you use in `axx-packs.yaml` ([Choose packs](/next/guides/use-packs/)). An AWS or Google Cloud pack brings its core with it.
## Connect as your service does
[Section titled “Connect as your service does”](#connect-as-your-service-does)
Register the account, project or resource once, usually in the `Background`. A scenario then uses it everywhere:
```gherkin
Background:
Given the parcels aws account with the following properties:
| region | eu-west-1 |
| endpoint | http://${sys:local.host}:4566 |
| access key id | ${env:AWS_ACCESS_KEY_ID:-local} |
| secret access key | ${env:AWS_SECRET_ACCESS_KEY:-local} |
```
* **AWS:** `the {word} aws account` takes a `region`, and optionally an `endpoint`, a `profile` or static keys. Without keys, the SDK’s default credential chain applies.
* **Google Cloud:** `the {word} gcp project` takes a `project`, and optionally an `endpoint` or a `credentials` key file. Without them, Application Default Credentials apply.
* **Azure:** each service connects to its own resource. `the {word} azure storage account` takes a `connection string` or a `url`. `the {word} service bus namespace` takes a `connection string` or a `namespace`, plus a `management endpoint` for emulators.
An `asyncapi` row on an AWS account, a Google Cloud project or a Service Bus namespace names the AsyncAPI document its queues’ and topics’ messages follow ([Validate against AsyncAPI](/next/guides/validate-asyncapi/)).
An `endpoint` points every service of the account or project at an emulator. Leave it out, and the same features run against the cloud. Axx’s AWS clients also honor `AWS_ENDPOINT_URL`, but only the account’s `endpoint` addresses S3 by path, as an S3 emulator needs.
## Run the clouds locally
[Section titled “Run the clouds locally”](#run-the-clouds-locally)
Emulators such as [floci](https://floci.io) run AWS, Google Cloud and Azure on your machine: one container per cloud, free, with no account needed. Start one next to your service in the Compose file your [service definition](/next/guides/manage-services/) runs, and point your service at it the way its SDK expects:
* `AWS_ENDPOINT_URL` for AWS;
* `STORAGE_EMULATOR_HOST`, `PUBSUB_EMULATOR_HOST` and `FIRESTORE_EMULATOR_HOST` for Google Cloud;
* connection strings for Azure.
compose.yaml
```yaml
services:
aws:
image: floci/floci:latest
ports: ['4566:4566']
environment:
FLOCI_HOSTNAME: aws
claims:
build: ../app
environment:
AWS_REGION: eu-west-1
AWS_ENDPOINT_URL: http://aws:4566
```
Create the buckets, queues, topics and tables the way your infrastructure code does in a real account. Scenarios bring the data: files, seeds and messages. The examples run a one-shot `provision` command before the service starts.
For a service that consumes Service Bus messages, use Microsoft’s [Service Bus emulator](https://learn.microsoft.com/azure/service-bus-messaging/overview-emulator) rather than floci’s. With floci-az, a message that arrives while the service is busy with another can stay locked until its lock expires, a minute by default, so the service sees it too late for a check. The `customs-clearance` example runs Service Bus in Microsoft’s emulator and Blob Storage in floci. Its management API is on its own port: give axx’s namespace `management endpoint` `http://localhost:5300`.
## Seed data
[Section titled “Seed data”](#seed-data)
A seed is a YAML or JSON file that gives a scenario’s data: the items of DynamoDB tables, the rows of BigQuery tables, or the documents of Firestore collections, by ID.
```gherkin
Given a seeds/shipments.yaml firestore seed
```
Firestore stores dates as timestamps, and a Firestore seed keeps YAML’s dates:
seeds/shipments.yaml
```yaml
shipments:
PX-5101:
carrier: KESTREL
weightKg: 2.5
weighedAt: 2026-09-01T08:12:00Z # a timestamp
manifest: "2026-09-01" # a string
```
* **An unquoted date is a timestamp.** A date alone (`2026-09-01`) is midnight UTC. A date and time can have fractional seconds and an offset (`2026-09-01T10:12:00.25+02:00`); without an offset, it is in UTC. A value tagged `!!timestamp` is a timestamp too.
* **A quoted date is a string.** Quote a value to keep it a string, such as a manifest named by its date.
* **JSON seeds hold strings.** JSON has no date type, so the dates of a JSON seed are strings.
Checks read timestamps back in RFC 3339 and UTC, so `| weighedAt | 2026-09-01T08:12:00Z |` checks the document above.
## Send a service messages
[Section titled “Send a service messages”](#send-a-service-messages)
A service that consumes messages gets them the way it does in the cloud: publish to the topic it reads, and let the subscription deliver them.
```gherkin
When the disputes/kestrel-overcharge.json message is published to the carrier-disputes pubsub topic
```
A service that Pub/Sub pushes to gets what a push subscription sends: an envelope whose `message.data` is the message, base64-encoded, with its attributes and ID. Create the push subscription, with the service’s address as its endpoint, where your other emulator resources are created, and publish to its topic: an emulator with push subscriptions, such as the gcloud CLI’s, pushes the message to the service as Pub/Sub does. The scenario neither builds the envelope nor encodes the data.
## What the checks do
[Section titled “What the checks do”](#what-the-checks-do)
* **They wait.** Services act asynchronously, so every check waits: 10 seconds, or the time `within {duration}` gives.
* **They see the scenario’s messages only.** A message check counts only what arrived since its scenario started. Scenarios run in parallel, so match on data unique to the scenario, such as a claim or an invoice ID.
* **Topics and buses cost nobody a message.** For the topics and buses a run checks, Axx subscribes a listener of its own before the scenarios start, and removes it when the run ends:
* an SQS queue for an SNS topic;
* a subscription for a Pub/Sub topic;
* a rule and a queue for an EventBridge bus;
* a subscription for a Service Bus topic.
* **Checking a queue takes its messages.** On an SQS or Service Bus queue, Axx consumes each message like any other consumer would. So check the queues your service writes to. Check a queue it consumes by what the service does with the messages.
* **Conditions compare text.** A `| field | value |` row compares the value as text, with a dotted path into nested data. `null` means null and `undefined` means absent. On messages, `attribute ` or `property ` rows check the values sent with the message.
* **Objects are read by their type.** Besides its exact content and its JSON properties, a check can look for text in an object, in a PDF, a Word document or a workbook too, or for a row of a CSV file or a workbook ([Check files](/next/guides/check-files/)):
```gherkin
Then within 30s the invoices/kestrel-2026-09.pdf object in the carrier-invoices gcs bucket contains "Total due: 1284.50 EUR"
And the disputes/kestrel-2026-09.csv object in the carrier-invoices gcs bucket has a row where:
| parcel | PX-KES-1001 |
| reason | weight differs |
```
## Examples
[Section titled “Examples”](#examples)
Each of these examples tests a service built on one cloud, with that cloud running in floci:
* [`parcel-claims`](https://github.com/nimbusxr/axx/tree/main/examples/parcel-claims), on AWS. Shops claim for damaged or lost parcels. Evidence photos in S3 trigger the decision; refunds, decisions and events go out over SQS, SNS and EventBridge.
* [`carrier-billing`](https://github.com/nimbusxr/axx/tree/main/examples/carrier-billing), on Google Cloud. Carriers’ invoices uploaded to Cloud Storage are priced from BigQuery and Firestore, and disputes are published on Pub/Sub.
* [`customs-clearance`](https://github.com/nimbusxr/axx/tree/main/examples/customs-clearance), on Azure. Declarations filed on Service Bus are cleared from invoices in Blob Storage.
# Test desktop apps
> Use your macOS, Windows and Linux apps the way people do, through the accessibility tree screen readers read: click and fill their controls by the names people see, press keys, draw, check what they show; each scenario with a clean app, and one feature for every OS.
Many acceptance criteria describe what a person does in a desktop app: a depot clerk registers the parcels that arrive, has the courier sign for them, and closes the day. The `desktop-macos`, `desktop-windows` and `desktop-linux` packs do exactly that, with the app as it ships: they read it through the operating system’s accessibility tree, what screen readers read, and click and type with the pointer and the keyboard, as a person does. Nothing is built into the app for testing.
```gherkin
Scenario: A clerk registers a fragile express parcel with a label
Given the depot app is launched
When the "Reference" field in the depot app is filled with "PX-DSK-4201"
And the "Fragile" checkbox is clicked in the depot app
And the "Express" element is clicked in the depot app
And the "Print label" element is clicked in the depot app
And the "Register" button is clicked in the depot app
Then the depot app shows "Registered PX-DSK-4201: Express, fragile, label printed"
And within 5s the "arrivals.json" file in the desk folder contains "PX-DSK-4201"
```
The steps are the same on every OS, and the same as a phone app’s: only the app’s registration says which OS it is for. Add the pack for each OS you test with `axx pack add desktop-macos`, `axx pack add desktop-windows` or `axx pack add desktop-linux` (each builds on `desktop-core`, the steps only desktops have, and on `app-core`, the steps every app has; [Choose packs](/next/guides/use-packs/)).
The parcels example’s depot desk (`examples/parcels/depot-desk`) is one desk built with every toolkit the packs cover, and one feature runs against every build: AppKit, SwiftUI, Qt, Swing, Electron, Tauri and Flutter on macOS; Windows Forms, WPF, WinUI, Qt, Swing, Electron, Tauri and Flutter on Windows; GTK, Qt, Swing, Electron, Tauri and Flutter on Linux.
## What it needs
[Section titled “What it needs”](#what-it-needs)
* **macOS:** Accessibility and Screen Recording for the app that runs axx: your terminal, or your editor. axx reads other apps and takes their screenshots only with them (System Settings, Privacy & Security).
* **Windows:** a desktop to run on: a signed-in user’s session, not a service or an SSH session (a scheduled task for the signed-in user has one). axx brings an app to the front as the terminal it runs in lets it: run it from a terminal in front, or have the scheduled task open a console with `conhost.exe` (a task’s Windows Terminal cannot come to the front itself, and flashes instead: a taskbar that hides itself then shows in every screenshot). Java apps need a JDK’s Java Access Bridge, which comes with it.
* **Linux:** Xvfb, dbus-daemon and at-spi2-core (Debian and Ubuntu: `apt install xvfb dbus at-spi2-core`), and for Java apps java-atk-wrapper (`apt install libatk-wrapper-java-jni`). axx runs desktops of its own, so nothing shows on your screen. For Wayland, GNOME Shell (below).
`axx doctor` checks them, and names what is missing.
## Register the app
[Section titled “Register the app”](#register-the-app)
A feature registers the app once for each OS it runs on, and runs unchanged on each: a run uses the registration for the OS it runs on.
```gherkin
Background:
Given the depot macos app with the following properties:
| app | ../appkit/build/Depot desk.app |
And the depot windows app with the following properties:
| app | ../winforms/bin/Release/net8.0-windows/DepotDesk.exe |
| registry | Software\Parcels\Depot desk |
And the depot linux app with the following properties:
| app | python3 |
| args | ../gtk4/desk.py |
```
| Property | What it is |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app` | The app. macOS: a `.app` of the project, an installed app’s bundle identifier (`com.apple.TextEdit`), or an executable. Windows: an `.exe` of the project, or a command. Linux: an executable of the project (an AppImage too), or a command. |
| `args` | The arguments it starts with; a quoted one with spaces. A file of the project is relative to `axx.yaml`, as an option’s value too (`--config=depot.yaml`). |
| `env.` | An environment variable it starts with, like `env.PARCELS_API`. |
| `registry` (Windows) | The keys under `HKEY_CURRENT_USER` the app keeps its settings in, emptied before each scenario. |
| `preferences` (macOS) | The preferences domains the app keeps its settings in beyond its bundle identifier’s, emptied before each scenario: Qt’s `QSettings` names its own, after the app’s organization. |
| `locale`, `timezone` | The language and region (`de-DE`) and the time zone (`Europe/Berlin`) it starts in. Windows has neither of an app’s own: there the machine’s apply, and the run says so. |
The app starts when a step launches it. Every step names the app, so a scenario can drive two, and a web app or a phone app too. Where the app a step is for differs between platforms, a property can name it: `in the ${sys:screen.app} app`.
## Each scenario has a clean app
[Section titled “Each scenario has a clean app”](#each-scenario-has-a-clean-app)
Before the app starts in a scenario, what it keeps is emptied: its home, a folder of the project’s `.axx/desktop`, and what its OS keeps of it elsewhere (on macOS its bundle identifier’s preferences and the `preferences` it names, on Windows its `registry` keys; on Linux its settings are in its home). Your own settings in those places are kept aside during the run and put back when it ends. After the scenario, the app and every process it started are stopped. There is no switch that skips it: a scenario is one journey, and that journey is its own.
A desktop has one screen, one pointer and one keyboard focus, so on macOS and Windows desktop scenarios take the machine’s desktop in turns, while the run’s other scenarios go on alongside them. On Linux, axx runs desktops of its own, `desktops` at once (1 by default), each with its own buses and its own homes for the apps.
axx.yaml
```yaml
packs:
desktop-linux:
desktops: 2 # two desktop scenarios at once
```
## Its files
[Section titled “Its files”](#its-files)
An app’s files are a folder of the files pack whose `owner` is the app: `./` is where its OS keeps an app’s data, `~/` is its home ([Check files](/next/guides/check-files/)).
```gherkin
Given the desk folder with the following properties:
| owner | app:depot |
| path | ./Depot desk |
Then within 5s the "arrivals.json" file in the desk folder contains "PX-DSK-4205"
```
## Launch it
[Section titled “Launch it”](#launch-it)
```gherkin
When the depot app is launched
When the depot app is restarted
```
A restart keeps what the app stored, as quitting it and opening it again does; the next scenario’s reset does not.
An app that waits in the tray, with no window until it is used, is launched once its icon is there: in the menu bar’s status area on macOS, in the taskbar’s tray on Windows, in the tray of axx’s desktops on Linux (a StatusNotifierWatcher, as KDE and GNOME with AppIndicator support keep one). The icon is a `menu`, named by its tooltip, and the entries of the menu it opens are `menu item`s:
```gherkin
When the "Courier tracker" menu is clicked in the tracker app
And the "Pause tracking" menu item is clicked in the tracker app
```
### Apps the system runs
[Section titled “Apps the system runs”](#apps-the-system-runs)
What an app asks of the system shows in an app the system runs: the file manager it opens a folder in. Register that app with `owner: system`, once for each OS, and the scenario reads it as it runs. It is never reset, launched or stopped: the windows it shows during the scenario are closed as the scenario ends, as a person closes them, and the app runs on. On Linux, the desktop’s own starts on axx’s desktop when an app asks for it.
```gherkin
Given the files macos app with the following properties:
| app | com.apple.finder |
| owner | system |
And the files windows app with the following properties:
| app | explorer.exe |
| owner | system |
And the files linux app with the following properties:
| app | nautilus |
| owner | system |
When the "Courier tracker" menu is clicked in the tracker app
And the "Show the day's log" menu item is clicked in the tracker app
Then within 10s the files app shows "Courier logs"
```
## Click, fill and press keys
[Section titled “Click, fill and press keys”](#click-fill-and-press-keys)
```gherkin
When the "Register" button is clicked in the depot app
When the "Reference" field in the depot app is filled with "PX-DSK-4202"
When the Enter key is pressed in the depot app
When the ControlOrMeta+A key is pressed in the depot app
When the "PX-DSK-4138" row is scrolled into view in the depot app
When the "Depot" menu is clicked in the depot app
And the "Close day" menu item is clicked in the depot app
```
Controls are found by the names people see and screen readers read: a button by its text, a field by its label, a list item or a row by one of its texts, an image by its description. The kinds are `button`, `field`, `checkbox`, `radio button`, `switch`, `tab`, `menu`, `menu item`, `list item`, `row`, `link`, `image`, `text` and `element` (anything, by its text or its accessibility label). Toolkits draw some controls differently (a switch in one is a checkbox in another), so a feature that runs against several names those an `element`. When no name tells a control apart, `id=` (its accessibility identifier on macOS, its AutomationId on Windows, its `id` attribute on Linux) or `xpath=` finds it.
A control is clicked where it shows, after scrolling it into view as a person would. An app may draw a control anew as the step takes it (a web view lays itself out again after a click), and the control found then has no place on the screen: the step finds it again, a few times at most. Keys go to the app’s focused window, named as the web pack names them: `ControlOrMeta` is Command on macOS and Control elsewhere, so a shortcut runs unchanged on each OS.
What an app draws rather than names, like a signature pad or a map, is clicked or dragged at a place on it, in points from its top left:
```gherkin
When the "Courier signature" element in the depot app is clicked at 40, 40
When the pointer is dragged from 40, 30 to 140, 30 on the "Courier signature" element in the depot app
```
What keeps its place in the middle of a control, or by one of its corners, as the control grows with the window or the screen (a picture shown in the middle, a button in a corner) is placed from there instead: from its `middle`, `top left`, `top right`, `bottom left` or `bottom right`. Places run right and down, so one left of or above that point is negative:
```gherkin
When the pointer is dragged from -60, 10 to 60, -10 from the middle of the "Courier signature" element in the depot app
When the "Courier signature" element in the depot app is clicked at -40, -30 from its bottom right
```
A place is on the control, or the step fails: a click off it would reach something else (a drag may end off it). Points are the screen’s, so an app that lays itself out in pixels and does not grow with Windows’ display scale (Windows Forms and Qt 5 apps that do not ask to) is smaller in points on a screen at 250%: the depot desk’s signature pad, 400 by 160 pixels, is 160 by 64 points there, and its scenarios sign inside that.
## Check what it shows
[Section titled “Check what it shows”](#check-what-it-shows)
```gherkin
Then the depot app shows "No parcels registered yet"
Then the depot app does not show "PX-DSK-4211"
Then the "Register" button is disabled in the depot app
Then the "Reference" field in the depot app has the value ""
Then the "Leipzig depot" image is shown in the depot app
Then within 20s the depot app looks like the "opened" screenshot
```
Checks wait for the app, 10 seconds or `within {duration}`. A failed step attaches the app’s window and its controls, as accessibility has them.
**Screenshots** are of the app’s front window, as it draws itself, in front and with the pointer away from it: `..png` in a `screenshots` folder next to the feature file, with the display’s scale when it is not 1 (`opened.darwin@2x.png`). Each platform draws windows its own way, so each has its own, compared on it only. A focused field’s text cursor is left out: it blinks. The first time, the step takes the screenshot and fails: look at it, keep it, and run again. `packs.desktop-core.screenshots` in `axx.yaml` sets the `folder`, the `tolerance`, the `platforms` a project keeps, and `update: true`, which takes them again.
## Traces and videos
[Section titled “Traces and videos”](#traces-and-videos)
Ask for a **trace** of each app, and a scenario keeps one in `.axx/desktop/traces`: a page with its window after each step, and its controls where it failed. A trace captures the window after every step, while the next step finds what it acts on: kept for failed scenarios only, it still slows every step a little (about a tenth, on macOS), so it is off until you ask.
A **video** is the scenario’s desktop as it ran, the pointer drawn where it was, with the scenario’s steps beside it: each one to run, running, passed, failed (its error under it) or skipped, and the lines the running step logs, so what happens off the screen shows too (a call to a service, a file checked). It opens on its first frame for a moment, the steps from the first (those that ran before it had a screen, such as the apps the scenario declares, among them), and its last frame shows for two seconds, the steps as they ended. It is an MP4 file (H.264) in `.axx/desktop/videos` that plays wherever videos play, with a chapter for each step; a failed scenario’s is attached to the report too. The run keeps one more, `run.mp4`: its kept scenarios one after another, each after a card with its name and whether it passed, and a chapter for each. QuickTime Player, VLC, IINA and mpv list the chapters; a browser plays the video without them. On Linux a video shows the scenario’s whole screen, axx’s own; on macOS and Windows, whose screen is yours, it shows the scenario’s windows only, black around them. A screen that does not change adds nothing, so a scenario’s video is a few hundred kilobytes.
axx.yaml
```yaml
packs:
desktop-core:
traces: failed # always, failed or never (the default)
videos: failed # always, failed or never (the default)
```
Videos are encoded with Cisco’s OpenH264. OpenH264 Video Codec provided by Cisco Systems, Inc. The first time a scenario keeps a video, axx downloads Cisco’s build for your OS from Cisco (about half a megabyte), checks it, and keeps it in its cache; `videos: never`, the default, turns it off, and axx then downloads nothing. Cisco’s terms are in axx’s `THIRD_PARTY_NOTICES.md`.
## Toolkits report differently
[Section titled “Toolkits report differently”](#toolkits-report-differently)
Every toolkit reports to accessibility in its own way, and some leave things out: Flutter says every control is enabled on macOS and Windows, Java on Linux does not report what is typed into a field, Flutter on Linux gives no places on the screen. A scenario that checks what a toolkit does not report is tagged, and the profile for that build leaves it out, as the depot desk’s `axx.yaml` does:
axx.yaml
```yaml
profiles:
macos-flutter:
run: {tags: not @enabled-state}
properties: {desk.app: ../flutter/build/macos/Build/Products/Release/Depot desk.app}
```
Each build’s README in `examples/parcels/depot-desk` says what its toolkit gives accessibility.
## Linux: X11 or Wayland
[Section titled “Linux: X11 or Wayland”](#linux-x11-or-wayland)
axx’s Linux desktops are X11 (Xvfb) by default. With `display: wayland`, each scenario’s desktop is a headless GNOME Shell of its own instead, the desktop most Linux users run, and apps pick Wayland or X11 as they do on a GNOME desktop: GTK, Qt 6 and Electron run on Wayland, Java and Qt 5 on its Xwayland. It needs gnome-shell and pipewire, and xwayland for X11 apps (`apt install gnome-shell pipewire xwayland`), and a system bus.
axx.yaml
```yaml
profiles:
wayland:
packs: {desktop-linux: {display: wayland}}
```
`axx run --profile wayland` runs the Linux desktop scenarios on Wayland. Screenshots on Wayland show GNOME’s window frames, so keep them in a folder of their own (`packs.desktop-core.screenshots.folder`).
## Watch it
[Section titled “Watch it”](#watch-it)
`axx run --watch` runs the desktop scenarios where you can see them, one at a time, slowed down with `--slowdown 500ms` ([Watch a run](/next/guides/watch-runs/)). On Linux they then run on your own display.
## In CI
[Section titled “In CI”](#in-ci)
On a Linux runner, axx needs only its packages (`xvfb`, `dbus` and `at-spi2-core`; GNOME Shell and PipeWire for Wayland), and starts the desktops itself:
```yaml
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- run: sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb dbus at-spi2-core
- run: axx run --profile linux-gtk4
```
Screenshots are pixels, and pixels follow the libraries and fonts that draw them: a runner draws as its image does this week. Run the scenarios in an image of your own, and every run and every developer draws the same: axx’s CI runs the depot desk’s Linux builds that way, in the image of `examples/parcels/depot-desk/linux`, built once for each version of its Dockerfile and kept in the registry (the `desktop` job in `.github/workflows/ci.yml`). Hosted macOS and Windows runners change with their images, and draw at a scale of 1: their screenshots are not the ones a developer’s machine keeps.
# Test event streams
> Open the server-sent event streams (text/event-stream) of your services and check the events they send, by their type, their id and their JSON data.
A server-sent event stream is a response that doesn’t end: the service writes an event each time something happens, such as a parcel’s depot scans for a shop’s system, and ends the stream when there is nothing more to say. The `sse` pack opens your services’ streams and checks the events they send.
```gherkin
Scenario: A shop's system hears that the parcel is delivered
Given a seeds/live-delivered.yaml db seed
And the tracking event stream with the following properties:
| url | http://localhost:8400/api/parcels/PX-LIV-5704/events |
And a depot-scans kafka event
And the depot-scans kafka event key is PX-LIV-5704
And the depot-scans kafka event payload is a kafka/scan-delivered.json resource
When the depot-scans kafka event is published using schema schemas/depot-scan.avsc
Then within 20s the tracking event stream has an event where:
| event type | delivered |
| reference | PX-LIV-5704 |
| status | DELIVERED |
```
Add the pack to the project with `axx pack add sse` ([Choose packs](/next/guides/use-packs/)).
## Open a stream
[Section titled “Open a stream”](#open-a-stream)
Register a stream under a name. It opens there and then, and belongs to the scenario, which closes it when it ends:
```gherkin
Given the tracking event stream with the following properties:
| url | https://parcels.example.com/api/parcels/PX-LIV-5704/events |
| header.Authorization | Bearer ${env:SHOP_TOKEN} |
```
| Property | What it is |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | The stream’s URL (required). |
| `header.` | A header of the request, such as a token. |
| `asyncapi` | The AsyncAPI document its events follow, each event type a message of it ([Validate against AsyncAPI](/next/guides/validate-asyncapi/)). |
A stream that doesn’t answer `200` with `text/event-stream` fails the step, and shows what the service answered instead, such as a `404` for an unknown parcel. `${env:..}` values are masked in logs and failures.
Open the stream before what makes the service send events, as in the scenario above: a check sees the events the stream sent since it opened.
## Check its events
[Section titled “Check its events”](#check-its-events)
A check waits for an event that meets it: 10 seconds, or the time `within` gives.
```gherkin
Then within 20s the tracking event stream has an event where:
| event type | scan |
| event id | 1 |
| reference | PX-LIV-5703 |
| status | OUT_FOR_DELIVERY |
And the tracking event stream has an event containing 'OUT_FOR_DELIVERY'
```
* `has an event where:` reads each event’s data as JSON: a path and the value as text, `null` for null and `undefined` for absent. Two rows read the event itself: `event type`, its `event:` field (`message` for an event without one), and `event id`, its `id:`.
* `has an event containing {string}` looks for text in any event’s data.
Comments and keep-alives are not events. An event’s data that spans several `data:` lines is read as one text, its lines joined with line breaks, as a browser reads it.
A failed check lists the events the stream sent. When the service ends the stream, or the connection breaks, a check still waiting fails at once, after looking at every event that came first.
## Keep scenarios apart
[Section titled “Keep scenarios apart”](#keep-scenarios-apart)
A stream belongs to its scenario: another scenario running in parallel never sees its events, and it closes when its scenario ends. Follow data unique to the scenario, such as a parcel of its own ([Isolate test data](/next/guides/isolate-test-data/)).
See the [sse pack’s reference](/next/references/packs/sse/) for every step.
# Call GraphQL services
> Send queries, mutations and subscriptions to your GraphQL services, a federated graph's gateway or a subgraph alone, check their data and errors against the schema, and mock the subgraphs of other teams field by field.
The `graphql` pack sends queries, mutations and subscriptions to GraphQL services and checks what they answer. A federated graph’s gateway and a subgraph alone are GraphQL services alike: register each at its URL, and the same steps test your subgraph on its own and the graph through the gateway.
```gherkin
Scenario: A parcel through the graph comes with its shop
Given a seeds/graph-shop.yaml db seed
And the graph graphql service with the following properties:
| url | http://localhost:4000/graphql |
| schema | introspection |
When the graphql/parcel-with-shop.graphql query is sent to the graph graphql service with the following variables:
| reference | PX-GQL-7205 |
Then the graph graphql service answered without errors
And the graph graphql service's data has the following properties:
| parcel.status | IN_TRANSIT |
| parcel.shop.name | Alder & Ash |
```
Add the pack to the project with `axx pack add graphql` ([Choose packs](/next/guides/use-packs/)).
## Register the service
[Section titled “Register the service”](#register-the-service)
| Property | What it is |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `url` | The URL operations are posted to (required). |
| `schema` | Its schema: an SDL file of the project, a URL, or `introspection` to ask the service. Operations and answers are checked against it. |
| `header.` | A header sent with every operation, such as `header.Authorization`. |
| `timeout` | How long an operation may take. Default `10s`. |
| `subscriptions` | `graphql-ws` (the default: the `graphql-transport-ws` protocol) or `sse` (server-sent events). |
| `subscriptions url` | Where subscriptions go. Default: the `url`, with `ws://` or `wss://` for graphql-ws. |
`${env:..}` values are masked in logs and failures, and `${token:..}` names a token the scenario registered ([Test webhooks and tokens](/next/guides/webhooks-and-tokens/)). A subgraph’s SDL may use the federation directives (`@key`, `@link`…).
## Send queries and mutations
[Section titled “Send queries and mutations”](#send-queries-and-mutations)
Keep operations in `.graphql` files, and give their variables in a table:
graphql/hold-parcel.graphql
```graphql
mutation HoldParcel($reference: ID!, $until: String!, $reason: String) {
holdParcel(reference: $reference, until: $until, reason: $reason) {
reference
status
heldUntil
}
}
```
```gherkin
When the graphql/hold-parcel.graphql mutation is sent to the parcels graphql service with the following variables:
| reference | PX-GQL-7202 |
| until | 2026-10-05 |
| reason | recipient on holiday |
When a query is sent to the parcels graphql service:
"""
{ parcel(reference: "PX-GQL-7201") { status } }
"""
```
* **A variable takes the type the operation declares:** `10115` is text for a `String!` variable and a number for an `Int!`. Double quotes make a value text, `null` is null, and `undefined` leaves the variable out. A row can be a path into an input object (`address.postcode`).
* **The step says what the operation is:** sending a mutation file with “query” fails, which keeps the feature file honest about what it does.
* **Errors are the answer’s:** an answer with errors does not fail the step that sends the operation. Check them.
## Check the answer
[Section titled “Check the answer”](#check-the-answer)
```gherkin
Then the parcels graphql service answered without errors
And the parcels graphql service's data has the following properties:
| holdParcel.status | ON_HOLD |
| holdParcel.heldUntil | 2026-10-05 |
Then the parcels graphql service answered an error where:
| path | holdParcel |
| extensions.code | NOT_HOLDABLE |
```
* Data paths are dotted (`parcel.shop.name`) or JSONPath; `null` and `undefined` work as in the other property steps.
* An error’s `path` is its fields and indexes joined with dots: `parcel.shop.name`, `parcels.0.reference`. The check passes when one of the answer’s errors has every row.
* The checks read the service’s last answer in the scenario.
## Subscriptions
[Section titled “Subscriptions”](#subscriptions)
Start a subscription before what makes the service send its messages:
```gherkin
Scenario: A shop's app hears its parcel go out for delivery
Given a seeds/graph-scanned.yaml db seed
And the graphql/parcel-scanned.graphql subscription is started on the parcels graphql service with the following variables:
| reference | PX-GQL-7204 |
And a depot-scans kafka event
And the depot-scans kafka event key is PX-GQL-7204
And the depot-scans kafka event payload is a kafka/scan-out-for-delivery.json resource
When the depot-scans kafka event is published using schema schemas/depot-scan.avsc
Then within 20s the parcels graphql service's subscription received a message where:
| parcelScanned.status | OUT_FOR_DELIVERY |
```
* The check waits for a message whose data has those values: 10 seconds, or `within {duration}`. When the subscription has ended, it fails at once, saying why.
* **A subscription belongs to the scenario,** which ends it when it ends.
## Check against the schema
[Section titled “Check against the schema”](#check-against-the-schema)
With a `schema` row, every operation and its variables are checked against the schema before they are sent, and every answer’s data against the operation’s types. An operation that breaks the schema is not sent; the step fails and names the rule it broke:
| Key | Found when |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `validation.operation.` | the operation breaks a validation rule of the GraphQL spec (`FieldsOnCorrectType`, `KnownArgumentNames`, `ProvidedRequiredArguments`…) |
| `validation.variables.missing` | a required variable is missing |
| `validation.variables.type` | a variable is not of its type |
| `validation.response.nonNull` | a non-null field is null, without an error that explains it |
| `validation.response.type` | a field’s value is not of its type |
| `validation.response.enum` | a value is not one of its enum’s |
| `validation.response.missingField` | a field the operation selects is missing |
| `validation.response.unknownField` | the data has a field the operation does not select |
`ERROR` fails the step, `WARN` and `INFO` log the finding, and `IGNORE` drops it. A scenario that sends a broken operation on purpose, to see how the service refuses it, relaxes only what it breaks:
```gherkin
Scenario: The graph refuses a field it does not have
Given the GraphQL validation levels are:
| validation.operation.FieldsOnCorrectType | IGNORE |
When a query is sent to the graph graphql service:
"""
query SignedBy { parcel(reference: "PX-GQL-7299") { signedBy } }
"""
Then the graph graphql service answered an error where:
| message | Cannot query field "signedBy" on type "Parcel". |
```
A key also sets the keys below it (`validation.response` covers `validation.response.enum`), and the most specific key set wins. `packs.graphql.levels` in `axx.yaml` sets the defaults the scenarios’ levels are merged over.
## Federated graphs
[Section titled “Federated graphs”](#federated-graphs)
A federated graph has three parts to test, with the same steps:
* **Your subgraph on its own:** register it at its own URL. Queries, mutations and subscriptions reach your code directly, and its SDL is the contract.
* **The graph through its gateway:** register the gateway’s URL. A query crosses your subgraph and the others, as the gateway plans it, which is where composition and entity resolution break.
* **Your service as a client of someone’s graph:** mock that graph, and check what your service asked it.
In the example, a gateway ([Hive Gateway](https://the-guild.dev/graphql/hive/docs/gateway)) composes the service’s own subgraph with a shops subgraph that another team owns, mocked by WireMock (below). Axx does not include a gateway: it is a container in your `compose.yaml`, like the one you run in production (Apollo Router, Cosmo Router, Hive Gateway…), and Axx only talks to its URL.
## Mock GraphQL services and subgraphs
[Section titled “Mock GraphQL services and subgraphs”](#mock-graphql-services-and-subgraphs)
The Axx WireMock image mocks GraphQL services and subgraphs **field by field, from their schema**: it runs each operation against the schema, so any query your service or a gateway sends is answered, whatever fields it selects. A subgraph’s mock answers `_service` and `_entities`, so a gateway composes and plans with it as with the real one. See [Mock GraphQL services and subgraphs](/next/guides/mock-dependencies/#mock-graphql-services-and-subgraphs).
See the [graphql pack’s reference](/next/references/packs/graphql/) for every step.
# Call gRPC services
> Call your gRPC services, unary and server-streaming, with requests from tables or JSON files, and check their status, answers, metadata and streamed messages, read with your protos or the server's reflection.
The `grpc` pack calls your services over gRPC and checks what they answer: a unary call’s status and answer, and the messages of a server stream as they come.
```gherkin
Scenario: A shop's system reads a parcel's status
Given a seeds/grpc-parcel.yaml db seed
And the tracking grpc service with the following properties:
| address | localhost:8410 |
When the parcels.tracking.v1.Tracking/GetParcel method is called on the tracking grpc service with the following fields:
| reference | PX-GRP-6101 |
Then the tracking grpc service answered OK
And the tracking grpc service's answer has the following fields:
| status | REGISTERED |
| serviceLevel | EXPRESS |
| lastScan | undefined |
```
Add the pack to the project with `axx pack add grpc` ([Choose packs](/next/guides/use-packs/)). To mock the gRPC services *your* service calls, see [Mock gRPC dependencies](/next/guides/mock-dependencies/#mock-grpc-dependencies).
## Register the service
[Section titled “Register the service”](#register-the-service)
```gherkin
Background:
Given the tracking grpc service with the following properties:
| address | ${sys:local.host}:8410 |
| proto | protos/tracking.proto |
| header.authorization | Bearer ${env:SHOP_TOKEN} |
```
| Property | What it is |
| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `address` | The service’s `host:port` (required). |
| `proto` | Its `.proto` file, compiled by Axx with its imports, or a descriptor set (`protoc --descriptor_set_out --include_imports`). |
| `tls` | `true` for a TLS connection, checked against the system’s certificates. Default `false`. |
| `header.` | Metadata sent with every call, such as a token. |
| `timeout` | The deadline of each unary call. Default `10s`. |
**The proto is the contract.** Without a `proto`, Axx reads the service’s descriptors through its [server reflection](https://grpc.io/docs/guides/reflection/), once per run. Requests are built with them, so a field the request message does not have fails the step before the call is made. Answers are read with them.
`${env:..}` values are masked in logs and failures, and `${token:..}` names a token the scenario registered ([Test webhooks and tokens](/next/guides/webhooks-and-tokens/)).
## Call a method
[Section titled “Call a method”](#call-a-method)
A method is `package.Service/Method`, as [grpcurl](https://github.com/fullstorydev/grpcurl) names it. `Service/Method` or `Method` do when only one of the service’s methods has that name.
```gherkin
When the Tracking/GetParcel method is called on the tracking grpc service with the following fields:
| reference | PX-GRP-6101 |
When the Tracking/GetParcel method is called on the tracking grpc service with the grpc/get-parcel.json message
When the Tracking/GetParcel method is called on the tracking grpc service with the grpc/get-parcel.json message and the following fields:
| reference | PX-GRP-6102 |
When the Tracking/ListDepots method is called on the tracking grpc service
```
* **The request is proto JSON:** fields by their JSON names (`lastScan.location`) or their proto names (`last_scan.location`), enums by name, 64-bit integers as numbers or text.
* **A table sets paths into the request,** as the REST pack’s request properties do. A value takes the type of its field, so `10115` is text for a string field. Double quotes make a value text, `null` leaves the field unset, and `undefined` removes it.
* **A status other than OK** does not fail the step that makes the call: check it. Only a call that cannot be made (an unknown method, a request that is not the method’s message) fails the step.
## Check the answer
[Section titled “Check the answer”](#check-the-answer)
```gherkin
Then the tracking grpc service answered OK
Then the tracking grpc service answered NOT_FOUND with a message containing 'PX-GRP-6199'
And the tracking grpc service's answer has the following fields:
| reference | PX-GRP-6101 |
| lastScan.location | Leipzig |
And the tracking grpc service's answer has the following metadata:
| x-served-by | tracking-v1 |
```
* **Statuses** are gRPC’s names: `OK`, `CANCELLED`, `INVALID_ARGUMENT`, `NOT_FOUND`, `FAILED_PRECONDITION`, `UNAVAILABLE`, and so on, or their numbers.
* **The answer is proto JSON,** with the fields that have their default values (`0`, `""`, `false`): an unset message field, such as a parcel’s `lastScan` before its first scan, is `undefined`.
* **Metadata** is the call’s headers and trailers, by name in any case; several values are joined with `, `.
* **The checks read the service’s last call** in the scenario.
## Watch a server stream
[Section titled “Watch a server stream”](#watch-a-server-stream)
A server-streaming method starts when it is called, and its messages come in as the scenario goes on. Start it before what makes the service send them:
```gherkin
Scenario: A shop's system watches its parcel go out for delivery
Given a seeds/grpc-watch.yaml db seed
And the Tracking/WatchParcel method is called on the tracking grpc service with the following fields:
| reference | PX-GRP-6102 |
And a depot-scans kafka event
And the depot-scans kafka event key is PX-GRP-6102
And the depot-scans kafka event payload is a kafka/scan-out-for-delivery.json resource
When the depot-scans kafka event is published using schema schemas/depot-scan.avsc
Then within 20s the tracking grpc service streamed a message where:
| reference | PX-GRP-6102 |
| status | OUT_FOR_DELIVERY |
```
* `streamed a message where:` waits for a message with those values: 10 seconds, or `within {duration}`. When the stream has ended, it fails at once, with the status it ended with.
* `answered OK` waits for the stream to end, and checks the status it ended with.
* **The stream belongs to the scenario,** which closes it when it ends.
Client-streaming and bidirectional methods are not supported.
## Keep scenarios apart
[Section titled “Keep scenarios apart”](#keep-scenarios-apart)
Scenarios run in parallel against the same service, so each calls with data of its own, such as its parcel’s reference, and its streams see only what their calls asked for.
See the [grpc pack’s reference](/next/references/packs/grpc/) for every step.
# Call JSON-RPC services
> Call your JSON-RPC 2.0 services with params from tables or doc strings, check their results and errors, and check every call against the service's OpenRPC document, with levels to relax a rule.
The `jsonrpc` pack calls your JSON-RPC 2.0 services over HTTP, and checks the results and errors they answer. With the service’s [OpenRPC](https://open-rpc.org/) document, every call is also checked against its contract, as the REST pack checks requests against OpenAPI.
```gherkin
Scenario: A depot holds a parcel until the day the recipient chose
Given a seeds/depot-hold.yaml db seed
And the depots jsonrpc service with the following properties:
| url | http://localhost:8400/rpc |
| openrpc | rpc.discover |
When the parcel.hold method is called on the depots jsonrpc service with the following params:
| reference | PX-RPC-7102 |
| until | 2026-10-05 |
| reason | recipient on holiday |
Then the depots jsonrpc service's result has the following properties:
| status | ON_HOLD |
| heldUntil | 2026-10-05 |
```
Add the pack to the project with `axx pack add jsonrpc` ([Choose packs](/next/guides/use-packs/)).
## Register the service
[Section titled “Register the service”](#register-the-service)
| Property | What it is |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `url` | The URL calls are posted to (required). |
| `header.` | A header sent with every call, such as `header.Authorization`. |
| `timeout` | How long a call may take. Default `10s`. |
| `openrpc` | The service’s OpenRPC document: a file of the project, a URL, or `rpc.discover` to ask the service for it with the method OpenRPC defines. |
`${env:..}` values are masked in logs and failures, and `${token:..}` names a token the scenario registered.
## Call a method
[Section titled “Call a method”](#call-a-method)
```gherkin
When the parcel.get method is called on the depots jsonrpc service with the params:
"""
["PX-RPC-7101"]
"""
When the parcel.hold method is called on the depots jsonrpc service with the following params:
| reference | PX-RPC-7102 |
| until | 2026-10-05 |
When the depot.list method is called on the depots jsonrpc service
```
* **A table gives params by name,** set as the REST pack’s request properties are: a row can be a path into a param (`address.postcode`), numbers and booleans are JSON’s, double quotes make a value text, `null` is null and `undefined` leaves it out. With an OpenRPC document, a param whose schema says `string` takes the cell as text.
* **A doc string** gives an object (by name) or an array (by position).
* **An error** does not fail the step that makes the call: check it. Only a service that does not answer JSON-RPC 2.0, such as an HTML error page, fails it.
## Check the result or the error
[Section titled “Check the result or the error”](#check-the-result-or-the-error)
```gherkin
Then the depots jsonrpc service's result has the following properties:
| reference | PX-RPC-7101 |
| heldUntil | undefined |
Then the depots jsonrpc service's result is 'true'
Then the depots jsonrpc service answered the error 4009 with a message containing 'can no longer be held'
And the depots jsonrpc service's error has the following properties:
| data.status | OUT_FOR_DELIVERY |
```
* `result is` compares a text result’s text, or any other result’s JSON.
* The error’s properties are its `code`, its `message` and its `data`.
* The checks read the service’s last call in the scenario.
## Check calls against OpenRPC
[Section titled “Check calls against OpenRPC”](#check-calls-against-openrpc)
With an `openrpc` row, every call’s params are checked against its method before it is sent, and every result and error when it comes back. A call that breaks the document fails the step that makes it, and names the rule it broke:
| Key | Found when |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `validation.method.unknown` | the document has no such method |
| `validation.params.structure` | the params are by name for a method that takes them by position (`paramStructure`), or the other way round |
| `validation.params.missing` | a required param is missing |
| `validation.params.unknown` | a param the method does not have |
| `validation.params.schema.` | a param breaks a keyword of its schema (`required`, `type`, `pattern`…) |
| `validation.result.schema.` | the result breaks a keyword of its schema |
| `validation.error.unknown` | the error is not one the method declares, nor one of JSON-RPC’s own (-32700, -32600 to -32603, -32000 to -32099) |
The document’s schemas are JSON Schema, draft 7 unless they say otherwise, and its `$ref`s reach its components and other files.
### Relax a rule
[Section titled “Relax a rule”](#relax-a-rule)
`ERROR` fails the step, `WARN` and `INFO` log the finding, and `IGNORE` drops it. Everything is `ERROR` by default. A scenario that sends a broken call on purpose, to see what the service answers, relaxes only what it breaks:
```gherkin
Scenario: A hold without its day is refused
Given the OpenRPC validation levels are:
| validation.params.missing | IGNORE |
When the parcel.hold method is called on the depots jsonrpc service with the following params:
| reference | PX-RPC-7104 |
Then the depots jsonrpc service answered the error -32602 with a message containing 'until'
```
A key also sets the keys below it (`validation.params` covers `validation.params.schema.pattern`), and the most specific key set wins. `packs.jsonrpc.openrpc.levels` in `axx.yaml` sets the defaults the scenarios’ levels are merged over:
axx.yaml
```yaml
packs:
jsonrpc:
openrpc:
levels:
validation.error.unknown: WARN
```
See the [jsonrpc pack’s reference](/next/references/packs/jsonrpc/) for every step.
# Test Kafka with Avro
> Configure Kafka topic clients, build and publish Avro events with a Schema Registry, verify consumed events, and address several events or clusters in one scenario.
The Kafka steps publish the events your service consumes and check the events it produces, with Avro and a Schema Registry or as plain text.
## Register the cluster and a topic client
[Section titled “Register the cluster and a topic client”](#register-the-cluster-and-a-topic-client)
```gherkin
Background:
Given the events kafka service with the following properties:
| brokers | ${sys:local.host}:9092 |
And a depot-scans kafka topic client with the following properties:
| producer.value.serializer | io.confluent.kafka.serializers.KafkaAvroSerializer |
| producer.schema.registry.url | http://${sys:local.host}:9081 |
And a parcel-events kafka topic client with the following properties:
| consumer.value.deserializer | io.confluent.kafka.serializers.KafkaAvroDeserializer |
| consumer.schema.registry.url | http://${sys:local.host}:9081 |
```
The service consumes depot scans, so the suite publishes to `depot-scans`; it announces registered parcels on `parcel-events`, so the suite reads that topic. A topic the suite both publishes to and reads takes both sets of properties.
An `asyncapi` row on the service names the AsyncAPI document its topics’ events follow: every event a scenario publishes, and every event a check consumes, is checked against it, Avro events in Avro’s JSON encoding ([Validate against AsyncAPI](/next/guides/validate-asyncapi/)).
The topic client’s properties use the Kafka client property names, prefixed with `producer.` or `consumer.`. Axx translates them to its own Go client, so you do not need a JVM; the [Kafka pack reference](/next/references/packs/kafka/) lists every property it understands. Consumer group settings (`group.id`, `enable.auto.commit`) have no effect, because Axx reads topics without a group.
For a TLS-secured cluster:
```gherkin
Given a secure-events kafka topic client with the following properties:
| producer.security.protocol | SASL_SSL |
| producer.sasl.mechanism | SCRAM-SHA-512 |
| producer.sasl.jaas.config | org.apache.kafka.common.security.scram.ScramLoginModule required username="app" password="${env:KAFKA_PASSWORD}"; |
| producer.ssl.truststore.location | certs/truststore.p12 |
| producer.ssl.truststore.password | ${env:TRUSTSTORE_PASSWORD} |
```
The consumer takes the same settings with the `consumer.` prefix.
For a topic on a different cluster, register that cluster and the client on it explicitly:
```gherkin
Given the depots kafka service with the following properties:
| brokers | depots-kafka:9092 |
And a depot-scans kafka topic client on the depots kafka service with the following properties:
| producer.value.serializer | io.confluent.kafka.serializers.KafkaAvroSerializer |
| producer.schema.registry.url | http://depots-registry:8081 |
```
## Build an event
[Section titled “Build an event”](#build-an-event)
```gherkin
Given a depot-scans kafka event
And the depot-scans kafka event key is PX-TRK-3002
And the depot-scans kafka event payload is a kafka/scan-delivered.json resource
And the depot-scans kafka event payload properties are:
| $.scanId | SC-3002-2 |
| $.parcelRef | PX-TRK-3002 |
And the depot-scans kafka event headers are:
| X-Scanner-Id | leipzig-dock-4 |
```
* The payload starts from a file in Avro’s JSON encoding. Union values are written as `{"string": "..."}` or `null`; set `packs.kafka.lenientUnions: true` in `axx.yaml` to accept bare values when only one branch fits.
* `the depot-scans kafka event payload properties are:` sets JSONPath properties of that topic’s first event; `the kafka event payload properties are:` uses the first topic client’s. Values are always set as **strings** (an empty cell sets null), and each property must already exist in the payload, so give numbers and objects their values in the file.
* `the depot-scans kafka event payload property is null` sets a property to JSON null (for an Avro union with `null`).
* Keep payload files schema-valid with [fixture factories](/next/guides/fixture-factories/) (the `avro` family); `kafka/scan-delivered.json` is generated that way.
## Publish
[Section titled “Publish”](#publish)
```gherkin
When the depot-scans kafka event is published using schema schemas/depot-scan.avsc
```
The payload is read with the Avro schema (`.avsc`, resolved against `resources`), the schema is registered under `-value` (or looked up, with `producer.auto.register.schemas=false`), and the record is sent in the Confluent wire format with the key and headers you set. A payload that does not fit the schema fails with the path of the mismatch, such as `$.location: expected string, got number 5`.
To publish without Avro (plain text or JSON), a topic client with the default configuration is enough: its `StringSerializer` sends the payload as text. Every event needs a topic client for its topic:
```gherkin
Given the delivery-notifications kafka topic client
And a delivery-notifications kafka event
And the delivery-notifications kafka event payload is a kafka/delivery-notification.json resource
When the delivery-notifications kafka event is published
```
## Verify consumed events
[Section titled “Verify consumed events”](#verify-consumed-events)
Check the events your service publishes. After a scenario registers parcel `PX-EVT-4001` through the API:
```gherkin
Then the parcel-events kafka event named registered key is PX-EVT-4001
And the parcel-events kafka event named registered payload properties are:
| $.reference | PX-EVT-4001 |
| $.weightGrams | 1200 |
| $.zone | DE-1 |
| $.source | api |
And the parcel-events kafka event named registered headers are:
| X-Event-Type | ParcelRegistered |
And the parcel-events kafka event named registered headers match:
| X-Event-Type | ^Parcel.*$ |
```
`named registered` names an expectation. Each step adds its checks to the name and passes when one record of the topic meets all of them at once, so the four steps above check that a single record has that key, those properties and those headers. Records are read from the start of the topic (with `consumer.auto.offset.reset=latest`, only those produced after the step starts), and a step waits up to 30 seconds (`packs.kafka.timeout`). Expected payload values are typed, so `1200` does not equal `1200.0` ([the rules](/next/references/packs/kafka/#kafkaconsumedproperties)). `headers are` needs each header exactly once with that value; `headers match` takes regular expressions.
When a step fails, the report lists the name’s checks and the latest records of the topic with the reason each one did not match.
## Test what your service does with an event
[Section titled “Test what your service does with an event”](#test-what-your-service-does-with-an-event)
Publishing and then consuming on the same topic only checks the wiring. An acceptance test publishes the event your service consumes and checks what the service does: a row in its database, a document, a response from its API, or an event on another topic. The parcels service keeps a tracking summary in MongoDB for every parcel the depots scan:
```gherkin
Scenario: A delivery scan marks the parcel delivered
Given a depot-scans kafka event
And the depot-scans kafka event key is PX-TRK-3003
And the depot-scans kafka event payload is a kafka/scan-delivered.json resource
And the depot-scans kafka event payload properties are:
| $.scanId | SC-3003-1 |
| $.parcelRef | PX-TRK-3003 |
When the depot-scans kafka event is published using schema schemas/depot-scan.avsc
Then within 20s a selection of at least 1 document is retrieved from the tracking collection where:
| _id | PX-TRK-3003 |
| delivered | true |
And the 1st document for the selection properties are:
| status | DELIVERED |
| lastLocation | Leipzig |
```
The `Background` registers the Kafka service, `the depot-scans kafka topic client` and the MongoDB database. The service handles the event in the background, so the `Then` step polls until the summary appears instead of sleeping.
## Several events in one scenario
[Section titled “Several events in one scenario”](#several-events-in-one-scenario)
Address events by ordinal when a scenario builds more than one. `a 2nd ordered ... kafka event` must follow the 1st; steps without an ordinal use the first event:
```gherkin
Given a 1st ordered depot-scans kafka event
And the 1st ordered depot-scans kafka event key is PX-TRK-3002
And the 1st ordered depot-scans kafka event payload is a kafka/scan-out-for-delivery.json resource
And the 1st ordered depot-scans kafka event payload properties are:
| $.scanId | SC-3002-1 |
| $.parcelRef | PX-TRK-3002 |
And a 2nd ordered depot-scans kafka event
And the 2nd ordered depot-scans kafka event key is PX-TRK-3002
And the 2nd ordered depot-scans kafka event payload is a kafka/scan-delivered.json resource
And the 2nd ordered depot-scans kafka event payload properties are:
| $.scanId | SC-3002-2 |
| $.parcelRef | PX-TRK-3002 |
When the 1st ordered depot-scans kafka event is published using schema schemas/depot-scan.avsc
And the 2nd ordered depot-scans kafka event is published using schema schemas/depot-scan.avsc
```
## Several clusters
[Section titled “Several clusters”](#several-clusters)
Every step has a form that names the Kafka service, such as `the depot-scans kafka event key is PX-TRK-3002 on the depots kafka service`. See the [step index](/next/references/step-index/) for the full list.
## Keep events apart
[Section titled “Keep events apart”](#keep-events-apart)
Topics keep their events across runs, and scenarios run in parallel. Use a key and identifiers that belong to one scenario (a parcel reference, a scan id), and assert on them. For fixture files, a generated `axx lint` rule can enforce that every event file owns a unique id, such as the `scanId` of every depot scan ([Isolate test data](/next/guides/isolate-test-data/)).
# Test MCP servers
> Call the tools of your MCP server as an AI assistant calls them, over stdio or HTTP, with the tools' own schemas as the contract; read its resources and prompts; and mock the MCP servers your service calls.
An MCP server (Model Context Protocol) offers AI assistants tools, like `track_parcel`: the assistant reads each tool’s name, description and argument schema, calls tools as it works, and gets their results back. The `mcp` pack tests your server by doing what an assistant does: it calls the tools and checks what they answer. No model is involved, so the scenarios are deterministic.
```gherkin
Scenario: An assistant tracks a parcel
Given a seeds/mcp-track.yaml db seed
And the parcels mcp server with the following properties:
| url | http://localhost:8400/mcp |
When the track_parcel tool is called on the parcels mcp server with the following arguments:
| reference | PX-MCP-9101 |
Then the track_parcel tool's result is not an error
And the track_parcel tool's result has the following properties:
| status | OUT_FOR_DELIVERY |
| serviceLevel | EXPRESS |
```
Add the pack to the project with `axx pack add mcp` ([Choose packs](/next/guides/use-packs/)).
## Register the server
[Section titled “Register the server”](#register-the-server)
A server is reached over streamable HTTP (a `url`), or run over stdio (a `command`), as assistants run local servers.
Which one follows how the server’s clients use it:
* **A server its clients start, over stdio.** Claude Code, Cursor and the other assistants run a local server as their own child process and talk to it through its stdin and stdout; nothing else can reach it. In a scenario, the scenario is the client, so the step runs it with `command`, the way the cli pack runs a command-line tool. It is not a service: nothing goes under `services:` in `axx.yaml`. Each scenario runs its own server, so a server that keeps data on disk can keep each scenario’s apart (point its data folder, through `env.`, at a folder of the scenario’s own).
* **A server that runs on its own, over HTTP,** deployed where clients connect to it. axx starts it as a service in `axx.yaml` ([Manage the services under test](/next/guides/manage-services/)), and the step connects with `url`.
Test a server the way its clients reach it: a stdio server switched to HTTP only for its tests is tested over a transport its users never use.
```gherkin
Given the desk mcp server with the following properties:
| command | docker compose exec -T app parcels mcp |
| dir | ../infra |
```
| Property | What it is |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | The server’s streamable HTTP endpoint. |
| `command` | The command that runs the server over stdio, split into words without a shell. The scenario runs it, and ends it, and whatever it started, when it ends. |
| `dir` | The folder the command runs in, relative to the project. Default: a folder of the scenario’s own. |
| `env.` | An environment variable of the command. |
| `header.` | A header sent with every request to the url, such as `header.Authorization`. |
| `timeout` | How long connecting, or a call, may take. Default `30s`. |
| `protocol version` | The MCP version to speak, like `2025-06-18`, to check that the server still answers an earlier one. Default: the latest the server speaks. |
The session agrees on the protocol version with the server: MCP 2026-07-28, which has no sessions, or an earlier one. `${env:..}` values are masked in logs and failures, and `${token:..}` names a token the scenario registered ([Test webhooks and tokens](/next/guides/webhooks-and-tokens/)).
## What an assistant sees
[Section titled “What an assistant sees”](#what-an-assistant-sees)
Before an assistant calls a tool, it reads the server’s list of tools: their titles, descriptions and hints. Check them, and check that a tool the server must not offer is not there:
```gherkin
Then the parcels mcp server has the track_parcel tool with the following properties:
| title | Track a parcel |
| annotations.readOnlyHint | true |
And the parcels mcp server does not have the cancel_parcel tool
```
## Call tools
[Section titled “Call tools”](#call-tools)
```gherkin
When the hold_parcel tool is called on the parcels mcp server with the following arguments:
| reference | PX-MCP-9102 |
| until | 2026-10-05 |
When the hold_parcel tool is called on the parcels mcp server with the mcp/hold-parcel.json arguments
```
* **An argument takes the type the tool’s input schema gives it:** `01067` is text for a string, and a number for an integer. Double quotes make a value text, `null` is null, and `undefined` leaves it out. A row can be a path into an argument (`address.postcode`, `lines[0].reference`).
* **A file’s arguments** are a JSON or YAML object.
* **A result that is an error does not fail the call,** nor does a call the server refuses: check them.
## Check results
[Section titled “Check results”](#check-results)
```gherkin
Then the hold_parcel tool's result has the following properties:
| status | ON_HOLD |
| heldUntil | 2026-10-05 |
Then the hold_parcel tool's result is an error
And the hold_parcel tool's result contains 'it can no longer be held'
Then the cancel_parcel tool's call failed with the error code -32602
```
A tool fails in two ways, which the checks tell apart:
* **The tool ran, and says what went wrong:** its result is an error (`is an error`), with text the assistant reads, to tell the user or try again. A parcel that can no longer be held is one.
* **The server refused the call:** a JSON-RPC error, with its code (`failed with the error code`): `-32602` for a tool it does not have or invalid parameters.
`has the following properties` reads the tool’s structured result, or the JSON of its text for a tool that answers JSON as text. `contains` reads its text and its structured result. The checks read the tool’s latest result in the scenario; when two servers of the scenario have the same tool, name the server: `the track_parcel tool's result on the desk mcp server ...`.
## The schemas are the contract
[Section titled “The schemas are the contract”](#the-schemas-are-the-contract)
A tool’s arguments are checked against its input schema before they are sent, and its structured result against its output schema. A finding fails the step:
| Key | Found when |
| --------------------------------------- | ------------------------------------------------------------------------- |
| `validation.arguments.schema.` | the arguments break the tool’s input schema (`required`, `type`, `enum`…) |
| `validation.result.schema.` | the structured result breaks the tool’s output schema |
| `validation.result.missing` | a tool with an output schema answered no structured result |
A scenario that sends broken arguments on purpose, to see how the server refuses them, relaxes only what it breaks:
```gherkin
Scenario: A hold without its day is refused by the server too
Given the MCP validation levels are:
| validation.arguments.schema.required | IGNORE |
When the hold_parcel tool is called on the parcels mcp server with the following arguments:
| reference | PX-MCP-9102 |
Then the hold_parcel tool's result is an error
```
`ERROR` (or `FAIL`) fails the step, `WARN` and `INFO` log the finding, and `IGNORE` drops it. A key also sets the keys below it, and `packs.mcp.levels` in `axx.yaml` sets the defaults the scenarios’ levels are merged over.
## Resources and prompts
[Section titled “Resources and prompts”](#resources-and-prompts)
```gherkin
When the parcels://PX-MCP-9103/label resource is read from the parcels mcp server
Then the parcels://PX-MCP-9103/label resource has the following properties:
| reference | PX-MCP-9103 |
When the delivery_update prompt is requested from the parcels mcp server with the following arguments:
| reference | PX-MCP-9103 |
Then the delivery_update prompt contains 'parcel PX-MCP-9103'
```
A resource is read by its URI; `has the following properties` reads its text as JSON. A prompt’s check reads the text of its messages.
## Mock the MCP servers your service calls
[Section titled “Mock the MCP servers your service calls”](#mock-the-mcp-servers-your-service-calls)
When your service calls another team’s MCP server, the Axx WireMock image mocks it from a description of its tools, with a mapping file per answer, and the mock pack checks what your service called. See [Mock MCP servers and A2A agents](/next/guides/mock-dependencies/#mock-mcp-servers-and-a2a-agents).
See the [mcp pack’s reference](/next/references/packs/mcp/) for every step.
# Test message brokers
> Send and publish messages to AMQP queues and exchanges (RabbitMQ, ActiveMQ Artemis), MQTT topics and NATS subjects, and check the messages your services send there, JetStream streams included.
Services talk through brokers: label printers report over AMQP, a depot’s handheld scanners publish over MQTT, and couriers’ confirmations and tracking updates fan out over NATS. The `amqp`, `mqtt` and `nats` packs let a scenario do what the other side does, such as publishing the printer’s report. The scenario then checks what your service did: the row it wrote, or the message it sent on.
```gherkin
Scenario: A report of a parcel the service does not know is set aside
When the amqp/label-printed-unknown.json message is published to the printers amqp exchange with the following properties:
| routing key | printed.LEJ |
| header printer | LEJ-3 |
Then within 10s the parcels.label-printed.rejected amqp queue has a message where:
| reference | PX-AMQ-8199 |
| header reason | unknown parcel |
| header printer | LEJ-3 |
```
## The packs
[Section titled “The packs”](#the-packs)
| Pack | Talks to | A step names |
| ------ | ----------------------------------------------------------------------------------- | ------------------------------------- |
| `amqp` | RabbitMQ over AMQP 0-9-1, and brokers that speak AMQP 1.0, such as ActiveMQ Artemis | an `amqp queue` or an `amqp exchange` |
| `mqtt` | MQTT 5 brokers: Mosquitto, EMQX, HiveMQ, RabbitMQ’s MQTT plugin | an `mqtt topic` |
| `nats` | NATS, with JetStream | a `nats subject` or a `nats stream` |
The three speak the same grammar as the cloud packs’ queues and topics ([Test cloud services](/next/guides/test-cloud-services/)). Add the ones you use with `axx pack add amqp mqtt nats` ([Choose packs](/next/guides/use-packs/)).
## Connect to the broker
[Section titled “Connect to the broker”](#connect-to-the-broker)
Register the broker once, usually in the `Background`. Every step of that pack in the scenario uses it:
```gherkin
Background:
Given the depot amqp broker with the following properties:
| url | amqp://parcels:${env:RABBITMQ_PASSWORD}@${sys:local.host}:5672/ |
And the depots mqtt broker with the following properties:
| url | mqtt://${sys:local.host}:1883 |
| username | depot-scanners |
| password | ${env:MQTT_PASSWORD} |
And the tracking nats server with the following properties:
| url | nats://${sys:local.host}:4222 |
| token | ${env:NATS_TOKEN} |
```
* **AMQP:** a `url` with the user, password and virtual host, and a `protocol`: `0-9-1` (the default) or `1.0`.
* **MQTT:** a `url` (`mqtt://`, `mqtts://`, or `ws://` and `wss://` for MQTT over WebSockets), and a `username` and `password`.
* **NATS:** a `url`, or several separated by commas, and one way to sign in: a `token`, a `username` and `password`, or a `creds` file.
`${env:..}` values, such as passwords, are masked in logs and failures, and so is a password written in a URL.
An `asyncapi` row names the AsyncAPI document the broker’s messages follow: every message a scenario sends there, and every message a check finds, is checked against it ([Validate against AsyncAPI](/next/guides/validate-asyncapi/)).
With AMQP 1.0, a queue is an anycast address and an exchange a multicast one, as ActiveMQ Artemis and Qpid have them, and the routing key is the message’s subject. RabbitMQ routes the same messages over 0-9-1 whatever protocol your services use, so keep the default for it.
## Send and publish
[Section titled “Send and publish”](#send-and-publish)
A message’s body is the doc string, or a file of the project:
```gherkin
When a message is published to the printers amqp exchange with the routing key 'printed.LEJ':
"""
{"reference": "PX-AMQ-8102", "printer": "LEJ-3", "printedAt": "2026-09-28T08:15:00Z"}
"""
When a message is sent to the parcels.label-printed amqp queue:
"""
{"printer": "LEJ-3", "job": "JOB-AMQ-8105"}
"""
When a message is published to the depots/LEJ/scans mqtt topic:
"""
{"scanId": "SC-8201-1", "parcelRef": "PX-MQT-8201", "status": "OUT_FOR_DELIVERY"}
"""
When the nats/PX-NAT-8303-delivered.json message is published to the deliveries.confirmed nats subject with the following headers:
| courier | CR-LEJ-12 |
```
A file’s message takes a table of what goes with it:
| Pack | Rows |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `amqp` | `routing key` (exchanges), `header `, `content type`, `correlation id`, `message id`, `reply to` |
| `mqtt` | `qos` (`1` unless it says otherwise), `retain` (`false`), `property ` (user properties), `content type`, `response topic`, `correlation data` |
| `nats` | a header on each row: its name and its value |
A few more things happen when a message is sent:
* **AMQP messages are persistent,** and a message whose body is JSON is sent as `application/json` unless a `content type` row says otherwise.
* **A message that goes nowhere fails the step:** the broker returns an AMQP message that no queue is bound for, and an MQTT broker may say nothing subscribes to a topic. The step says so, rather than letting the message vanish.
* **A NATS message waits for its stream:** when a JetStream stream captures the subject, the message is published through JetStream, and the step waits for the stream to store it.
## Check what your services sent
[Section titled “Check what your services sent”](#check-what-your-services-sent)
A check waits for a message that meets it: 10 seconds, or the time `within` gives. It looks only at the messages received since its scenario started.
```gherkin
Then within 10s the labels amqp exchange has a message where:
| routing key | print.express |
| reference | PX-AMQ-8101 |
| header sender | alder-and-ash |
Then within 10s the depots/+/alerts mqtt topic has a message where:
| topic | depots/LEJ/alerts |
| parcelRef | PX-MQT-8299 |
| property scanner | LEJ-HANDHELD-7 |
Then within 20s the TRACKING nats stream has a message where:
| subject | tracking.PX-NAT-8303 |
| header courier | CR-LEJ-12 |
```
Each row is a path into the message’s JSON body and the value it has, as text: `null` for null and `undefined` for absent. A few rows are on the message itself instead:
| Pack | Rows on the message |
| ------ | ------------------------------------------------------------------------------------------ |
| `amqp` | `header `, `routing key`, `content type`, `correlation id`, `message id`, `reply to` |
| `mqtt` | `property `, `topic`, `content type`, `response topic`, `correlation data` |
| `nats` | `header `, `subject` |
A check can name many topics or subjects at once: MQTT’s `+` and `#` (`the depots/+/alerts mqtt topic`) and NATS’s `*` and `>` (`the tracking.> nats subject`). A `topic` or `subject` row then checks the one a message came on.
## How the checks listen
[Section titled “How the checks listen”](#how-the-checks-listen)
For the targets a run’s checks name, axx starts listening once the services are up, before the first scenario, so no message is missed:
* **An AMQP queue** is read, and each message acknowledged, as any consumer would. Check the queues your services write to and nothing else reads.
* **An AMQP exchange** takes nothing from anyone. Axx declares a queue of its own for the run (`axx-.`, exclusive, deleted with the run), and binds it with `#` and with the routing keys the checks name. That covers topic, fanout, headers and direct exchanges.
* **An MQTT topic** gets a subscription of its own, on a connection of its own. It doesn’t receive the retained messages the broker keeps for new subscribers, which earlier runs may have left.
* **A NATS subject** gets a subscription of its own.
* **A JetStream stream** is read with an ordered consumer of its own, from the messages stored after the services are up, so what earlier runs stored never counts. It takes nothing from the stream’s other consumers. A work-queue stream allows no other consumer, so check its subject instead.
## Keep scenarios apart
[Section titled “Keep scenarios apart”](#keep-scenarios-apart)
Scenarios run in parallel on the same brokers, and every listener sees every scenario’s messages. So a check tells a scenario’s message apart by data unique to it, such as its own parcel reference ([Isolate test data](/next/guides/isolate-test-data/)). A queue the check reads is shared by the whole run: a message another scenario sent is still there for that scenario’s check.
## Run the brokers locally
[Section titled “Run the brokers locally”](#run-the-brokers-locally)
Start the brokers next to your service in the Compose file your [service definition](/next/guides/manage-services/) runs:
compose.yaml
```yaml
services:
rabbitmq:
image: rabbitmq:4.2.9-alpine
ports: ['5672:5672']
environment: { RABBITMQ_DEFAULT_USER: parcels, RABBITMQ_DEFAULT_PASS: parcels }
mosquitto:
image: eclipse-mosquitto:2.0.22
command: ['mosquitto', '-c', '/mosquitto-no-auth.conf']
ports: ['1883:1883']
nats:
image: nats:2.15.0-alpine
command: ['nats-server', '-js']
ports: ['4222:4222']
```
RabbitMQ’s default `guest` user can only connect from the broker’s own host, so give it a user of your own. Mosquitto only listens to its own host without a configuration of its own, and `/mosquitto-no-auth.conf` opens it up. NATS needs `-js` for JetStream.
See the [amqp](/next/references/packs/amqp/), [mqtt](/next/references/packs/mqtt/) and [nats](/next/references/packs/nats/) packs’ references for every step.
# Test mobile apps
> Use your Android and iOS apps the way people do, on emulators and simulators axx runs: tap and fill their controls by the names people see, open them with deep links, check what they show; each scenario with a device to itself and a clean app.
Many acceptance criteria describe what a person does in an app: a courier signs in, taps a parcel and marks it delivered. The `mobile-android` and `mobile-ios` packs do exactly that, on Android emulators and iOS simulators, driven by [Appium](https://appium.io), and a scenario can use every other pack too: it checks what your service did with the delivery, and what the parcel’s tracking page shows.
```gherkin
Scenario: A courier delivers a parcel, and its recipient sees it delivered
Given a seeds/courier-app-deliver.yaml db seed
When the courier app is opened with the "parcels-courier://deliveries/PX-MOB-9411" link
And the "Courier ID" field in the courier app is filled with "CR-LEJ-14"
And the "PIN" field in the courier app is filled with "${env:COURIER_PIN}"
And the "Sign in" button is tapped in the courier app
Then the courier app shows "Eisenbahnstr. 41, 04315 Leipzig"
When the "Signed by" field in the courier app is filled with "C. Busch"
And the "Mark delivered" button is tapped in the courier app
And the "Confirm" button is tapped in the courier app
Then the courier app shows "Delivered"
And the courier app shows a notification "PX-MOB-9411 delivered"
When the "/track/PX-MOB-9411" page is opened
Then within 20s the page shows "Delivered on"
```
The steps are the same on Android and on iOS: only the app’s registration says which it is. Add the pack for each platform you test with `axx pack add mobile-android` or `axx pack add mobile-ios` (both build on `mobile-core`, the steps of phones, and on `app-core`, the steps every app has, on a phone or a desktop; [Choose packs](/next/guides/use-packs/)).
## What it needs
[Section titled “What it needs”](#what-it-needs)
**For Android:**
* **The Android SDK**, with `ANDROID_HOME` pointing at it: its emulator, and adb. Android Studio installs it, or its command-line tools. An adb server axx starts has its mDNS discovery off (`ADB_MDNS=0`, unless you set `ADB_MDNS`): axx needs none, and on some networks it makes adb’s server abort.
* **An emulator’s device (AVD)**, like the one Android Studio’s Device Manager makes, or one `avdmanager` makes:
```sh
sdkmanager --install "system-images;android-35;google_apis;arm64-v8a" # x86_64 on Intel and AMD
avdmanager create avd --name parcels-pixel --package "system-images;android-35;google_apis;arm64-v8a" --device pixel_8
```
* **For an app’s ARCore** ([below](#what-the-camera-sees-and-ar)): the SDK’s NDK and build-tools, and a JDK, which axx builds its agent with, once.
**For iOS:**
* **A Mac with Xcode**, and an iOS runtime for its simulators (Xcode’s Settings, Components). axx makes the simulators itself.
* **The app built for the simulator**: the `.app` that `xcodebuild -sdk iphonesimulator` builds, or it zipped.
`axx doctor` checks them: the Android SDK and its devices (and KVM on Linux), Xcode and its iOS runtimes.
**Nothing else:** axx downloads what drives the apps the first time a run needs it, pinned, as it downloads a browser driver for web apps. For Android, that is the UiAutomator2 server, which axx installs on each device and starts itself, and keeps running while the device is in use: a scenario’s session starts in the time its app takes to reset. For iOS, it is WebDriverAgent, the app on the simulator that drives yours, as the [Appium](https://appium.io) project builds it, which axx starts on each simulator itself: nothing is built with Xcode, and nothing needs signing.
## Register the app
[Section titled “Register the app”](#register-the-app)
```gherkin
Given the courier android app with the following properties:
| apk | ../courier/android/app/build/outputs/apk/debug/app-debug.apk |
| device | parcels-pixel |
| permissions | POST_NOTIFICATIONS |
| timezone | Europe/Berlin |
| host ports | 8400 |
```
```gherkin
Given the courier ios app with the following properties:
| app | ../courier/ios/build/Debug-iphonesimulator/Courier.app |
| device | iPhone 16 |
| timezone | Europe/Berlin |
```
| Property | What it is |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apk` (Android), `app` (iOS) | The app, a file of the project: it is the app the scenario tests. On iOS, a simulator build (`.app`), or it zipped. |
| `package`, `activity` (Android), `bundle id` (iOS) | The app’s identity: with no `apk` or `app`, an app the device has. On iOS, the `app`’s own bundle identifier by default. |
| `device` | Android: an emulator’s device (AVD) axx starts, or a device adb lists, by its serial. iOS: a device type axx makes simulators of, like `iPhone 16` or `iPhone 16, iOS 18.1` (the newest iOS Xcode has, by default); a simulator you set up, by its name, which axx clones; or a simulator’s UDID. |
| `permissions` | What the app may use from the start, and nothing else. Android: permissions like `POST_NOTIFICATIONS`. iOS: services like `location, photos` (`xcrun simctl help privacy` lists them). |
| `locale`, `timezone`, `location` | The language and region (`de-DE`), the time zone (`Europe/Berlin`), and where the device says it is (`51.3397, 12.3731`). On Android the language is the app’s own (Android 13 and later), and the device’s stays as it is. |
| `photos`, `documents` | Files the phone has before the app starts, for the app to pick in the system’s pickers, like `photos/begin.jpg` or `documents/safe-place-authorization.pdf`: found as a step finds a file, next to the feature file first. Photos go in the photo library; documents where the file picker shows them (see [Pick a document](#pick-a-document)). |
| `host ports` (Android) | Ports of the machine axx runs on that the app reaches as `localhost` on the device, like `8400, 5500`: the app calls `http://localhost:8400` as on a developer’s device, and gets the service there. Each scenario has only its own registration’s. |
| `appium`, `capability.` | An Appium server of your own or a device farm’s, which runs the device, and the capabilities it takes. On Android without `appium`, the capabilities go to the UiAutomator2 server’s session. |
The app starts when a step launches it or opens a link in it. Every step names the app, so a scenario can drive two, and a web app too.
## Each scenario has a device, and a clean app
[Section titled “Each scenario has a device, and a clean app”](#each-scenario-has-a-device-and-a-clean-app)
A scenario leases a device for its whole run: no other scenario uses it meanwhile. The packs run devices as scenarios need them, up to `devices` of each at once (1 by default: those scenarios then take turns while the others run).
axx.yaml
```yaml
packs:
mobile-android:
devices: 2 # two emulators: two Android scenarios at once
mobile-ios:
devices: 2 # two simulators: two iOS scenarios at once
```
* **Android** starts emulators of the device read-only, so that several run at once and nothing a scenario changes outlives it. A device with a boot snapshot (boot it once, and close it) starts in seconds; without one, each emulator boots cold.
* **iOS** gives each device it runs a clone, deleted when the run ends: of the simulator you set up, or, for a device type, of axx’s own simulator of it. axx makes that one and boots it once to set it up, the same on every Mac, and keeps it in its cache (`~/Library/Caches/axx/mobile/simulators`, apart from Xcode’s). No scenario ever runs on it, and its clones boot in seconds. WebDriverAgent, the app that drives yours, starts once on each simulator, for all the scenarios that run on it.
Before its app starts, a scenario’s app is reset, and what it sees of the device is set:
* **Android:** its data cleared (and with it its sign-in, its permissions and its notifications), the permissions its registration names granted, the notification shade and any dialog of the system’s closed, the ports it reaches on this machine set, the photos and documents a scenario before put on the device taken out (and the registration’s put in), the system’s file picker started afresh, and the device’s language, time zone and location set: an emulator goes back to where it starts when the registration names none. On the emulators axx starts, other apps that hang or crash show no dialog over yours, and Chrome opens a page at once, without its first-run screens.
* **iOS:** the app installed afresh (its data and notifications go with the old one), the simulator’s keychain reset (a sign-in kept there outlives the app), its permissions reset and those its registration names granted, the documents a scenario before put On My iPhone taken out (and the registration’s put in), and the location set. It starts in the registration’s language, region and time zone.
There is no switch that skips it: a scenario is one journey, and that journey is its own. Its data stays unique as everywhere in axx: each scenario above has a courier and parcels of its own.
### Keep simulators warm
[Section titled “Keep simulators warm”](#keep-simulators-warm)
A run makes its iOS simulators and deletes them at its end. With `keep`, it keeps them booted for the next run instead, which then starts at once: in the parcels example, its three iOS scenarios ran in 41 seconds instead of 68.
axx.local.yaml
```yaml
packs:
mobile-ios:
keep: true
```
The kept simulators are in Xcode’s own set, named like `axx iPhone 17, iOS 27.0 (1)`, where Device Hub lists them. A run uses one only while no other run does, and each scenario still has its simulator to itself, its app reset. A watched run keeps them. To get rid of them, delete them in Device Hub, or with `xcrun simctl delete`.
## Run one platform
[Section titled “Run one platform”](#run-one-platform)
A profile per platform runs the scenarios of one app, with no tags on the features: `run.uses` picks the scenarios by the packs their steps use ([Select by what scenarios use](/next/guides/tags-and-filtering/#select-by-what-scenarios-use)).
axx.yaml
```yaml
profiles:
ios: {run: {uses: [mobile-ios]}}
android: {run: {uses: [mobile-android]}}
```
`axx run --profile ios` runs the iOS app’s scenarios, and `--profile ios,watch` runs them watched.
## Watch it
[Section titled “Watch it”](#watch-it)
`axx run --watch` shows what the scenarios do as they do it: an iOS simulator in Device Hub (the Simulator app before Xcode 27), an Android emulator in its window, a browser in its own, one scenario at a time, slowed down with `--slowdown 500ms` ([Watch a run](/next/guides/watch-runs/)). Without `--watch`, the devices run without a window, and a Device Hub you have open stays open.
## Launch it, or open it with a link
[Section titled “Launch it, or open it with a link”](#launch-it-or-open-it-with-a-link)
```gherkin
When the courier app is launched
When the courier app is opened with the "parcels-courier://deliveries/PX-MOB-9411" link
When the courier app is sent to the background
When the courier app is brought back
When the courier app is restarted
```
A deep link jumps straight to the screen a scenario tests, instead of tapping through the ones before it. A restart keeps what the app stored, like a sign-in; the next scenario’s reset does not.
## An app’s files
[Section titled “An app’s files”](#an-apps-files)
An app’s files are a folder of the files pack whose `owner` is the app: `./` is its data on the device (on iOS its data container, on Android its data folder, which Android opens to a debuggable build only) ([Check files](/next/guides/check-files/)). A scenario reads what the app saved there, and puts files in place before the app first reads them: the folder starts the app’s device, installs the app and resets it, as launching does, without launching it. A scenario about a returning user starts so, with the app’s settings as that user’s app has them:
```gherkin
Given the settings folder with the following properties:
| owner | app:courier |
| path | Library/Preferences |
And the example.parcels.courier.plist file in the settings folder is a copy of the fixtures/signed-in-before.plist file
When the courier app is launched
```
## Tap and fill
[Section titled “Tap and fill”](#tap-and-fill)
```gherkin
When the "Sign in" button is tapped in the courier app
When the "PX-MOB-9401" list item is tapped in the courier app
When the "PIN" field in the courier app is filled with "${env:COURIER_PIN}"
When the courier app is swiped down
When the "PX-MOB-9412" list item is scrolled into view in the courier app
When the courier app's back button is pressed
```
Controls are found by the names people see: a button by its text, a field by its label, a list item by one of its texts (its reference, say), an image by its description. The kinds are `button`, `field`, `checkbox`, `switch`, `tab`, `list item`, `image`, `text` and `element` (anything). Jetpack Compose and Android’s views both work, and SwiftUI and UIKit on iOS. When no name tells a control apart, `id=` (its resource ID on Android, its accessibility identifier on iOS) or `xpath=` finds it. `${env:..}` values are secrets: masked in logs and failures.
`swiped down` pulls a list to refresh; `up`, `left` and `right` swipe too. The back button is Android’s; on iOS, tap the back button the screen shows, by its text.
`scrolled into view` scrolls the list or text that holds the control until it shows, as a person does. A control far down a long text, like the end of a terms of service, is reached in one move on iOS, which grabs the scroll bar and drags it there, and in a few flings on Android.
## Check what it shows
[Section titled “Check what it shows”](#check-what-it-shows)
```gherkin
Then the courier app shows "Hello, Hanna Wolf"
Then the courier app does not show "PX-MOB-9401"
Then the "Mark delivered" button is disabled in the courier app
Then the "Courier ID" field in the courier app has the value "CR-LEJ-12"
Then the courier app shows a notification "PX-MOB-9411 delivered"
Then the courier app looks like the "deliveries" screenshot
```
Checks wait for the app, 10 seconds or `within {duration}`, as apps take their time. A failed step attaches what the app showed, and the scenario’s failure says which device and which resets it had. A notification is looked for where people look: Android’s notification shade, iOS’s Notification Center.
**Screenshots** are `..png` in a `screenshots` folder next to the feature file, like `deliveries.android-parcels-pixel.png` or `deliveries.ios-iPhone-16-iOS-18-1.png`: each device has its own. The device’s status and navigation bars, its clock among them, are left out. The first time, the step takes the screenshot and fails: look at it, keep it, and run again. `packs.mobile-core.screenshots` in `axx.yaml` sets the `folder`, the `tolerance` and `update: true`, which takes them again.
## Tap a notification
[Section titled “Tap a notification”](#tap-a-notification)
```gherkin
When the "PX-MOB-9402 delivered" notification is tapped in the courier app
When the "Next delivery" action of the "PX-MOB-9401 delivered" notification is tapped in the courier app
```
A notification is tapped where people find it: the step opens Android’s notification shade or iOS’s Notification Center, opens the group it is folded into (on iOS, one already seen waits below the screen, and is swiped into view), and taps it, and the app opens where the notification leads. An action is one of the buttons a notification offers: Android shows them under it, and iOS when a person holds the notification.
On a simulator, iOS answers a tap on a notification as on an iPhone without Face ID: it slides the notification aside to show Open, which the step taps too. And it shows a notification’s actions in Notification Center only on a phone it can tell is in a hand, one with a proximity sensor (or Face ID unlock, which takes a passcode a simulator cannot have). So axx gives the simulator’s SpringBoard the proximity sensor an iPhone has: a small library axx builds with Xcode the first time, and loads into the simulators it boots. In your app, it does only what [background tasks](#run-a-background-task-now) need. A simulator that runs already (one of yours, or one kept warm) gets it as SpringBoard starts again, before the scenario.
## Run a background task now
[Section titled “Run a background task now”](#run-a-background-task-now)
```gherkin
When the "round-reminder" background task is run in the courier app
Then within 30s the courier app shows a notification "2 deliveries left today"
```
An app schedules work for the phone to run later: a sync, a reminder, a check. The phone chooses when, every 15 minutes at the soonest on Android, and whenever it suits it on iOS. The step has the phone run one of the app’s tasks now, as it does when the task’s time comes. It is a nudge, not a stand-in: the app’s own code runs, in the app, and does what it does then. Only the moment moves. The app must have scheduled the task. The step waits for it to be done, so a check after it finds what it did: on iOS, until the app says it is done (`setTaskCompleted`), in the 30 seconds iOS gives a task; on Android, until the work ends, as WorkManager’s database has it.
The task is named as the app names it:
* **Android:** its WorkManager work’s unique name (`enqueueUniquePeriodicWork("round-reminder", ...)`), or one of its tags. WorkManager tags each piece of work with its worker’s class, so `RoundReminderWorker` names it too. axx asks Android’s job scheduler to run the work’s job now, and Android runs it only if the conditions the app set for it, a network say, are met. WorkManager runs periodic work no sooner than its period allows, so axx first tells it the period has passed, in its own database on the phone. That takes the phone’s `sqlite3`, which emulators with a Google APIs image have, and a debuggable build, as an app’s files do.
* **iOS:** its identifier, one of `BGTaskSchedulerPermittedIdentifiers` in its `Info.plist`. iOS runs the task as Xcode’s debugger command does on an iPhone: the handler the app registered gets the task the app scheduled, once for each time it scheduled it. A simulator refuses to schedule any task, so axx’s library, which the simulators it boots load, keeps the app’s requests as an iPhone does. An app in the background is suspended, and SpringBoard wakes it for the task, as iOS does, and holds it awake until the task is done.
A name the app does not have fails, and the failure lists the tasks it has.
## Pick a document
[Section titled “Pick a document”](#pick-a-document)
An app that takes a file from the phone, like a courier’s app attaching a recipient’s signed authorization to a delivery, opens the system’s file picker. The `documents` property puts the files on the phone before the app starts, and the scenario picks one as a person does, with the tap steps:
```gherkin
Given the courier android app with the following properties:
| apk | ../courier/android/app/build/outputs/apk/debug/app-debug.apk |
| device | Pixel_9 |
| documents | documents/safe-place-PX-MOB-9481.pdf |
And the courier app is opened with the "parcels-courier://deliveries/PX-MOB-9481" link
When the "Attach document" button is tapped in the courier app
And the "Show roots" button is tapped in the courier app
And the "Downloads" text is tapped in the courier app
And the "safe-place-PX-MOB-9481.pdf" text is tapped in the courier app
Then the courier app shows "safe-place-PX-MOB-9481.pdf attached"
```
* **Android** puts them in the device’s Downloads, indexed as a download is. The picker opens at Recent: *Show roots*, then *Downloads*, then the file by its name. On an emulator axx starts, each scenario’s reset empties Downloads (what an app saved there too) and starts the picker afresh, as it keeps where each app last picked a file; on a device of yours, it takes out only the documents axx put there.
* **iOS** puts them On My iPhone, in the simulator’s Files. In the picker, *Browse*, then *On My iPhone*, then the file as a list item, by its name without its extension, as Files shows it (below). On a simulator axx makes, each scenario’s reset empties On My iPhone (what an app saved there too); on a simulator of yours, it takes out only the documents axx put there.
```gherkin
When the "Attach document" button is tapped in the courier app
And the "Browse" button is tapped in the courier app
And the "On My iPhone" text is tapped in the courier app
And the "safe-place-PX-IOS-9591" list item is tapped in the courier app
```
The phone has its documents in one folder, by their names: two files of one name are refused. A device of an Appium server’s, like a device farm’s, takes files its own way.
## Dialogs the system shows
[Section titled “Dialogs the system shows”](#dialogs-the-system-shows)
```gherkin
Then the courier app's dialog shows "send you notifications"
When the courier app's dialog is dismissed
```
A dialog the system shows over the app, like a request for a permission it asked for, is accepted (allowed) or dismissed. iOS lets no one grant notifications ahead: an app that uses them asks, and the scenario answers. Accepted, a request with a choice of how to allow allows for good: *While using the app* for a location (not *Only this time*), *Allow Full Access* for photos. A dialog of the app’s own is part of its screen: tap its buttons, like `the "Confirm" button is tapped in the courier app`.
## Move the phone along a route
[Section titled “Move the phone along a route”](#move-the-phone-along-a-route)
The `location` property puts the phone in one place. An app that follows the phone as it moves, like a courier’s app sharing where the van is, needs it to travel, with its GPS reporting where it is, how fast it goes and which way:
```gherkin
When the courier app's phone travels at 30 km/h through:
| 51.3397 | 12.3731 |
| 51.3397 | 12.3760 |
```
The table’s rows are the points the route goes through, each a latitude and a longitude; the phone keeps one speed (`mph`, `km/h` or `m/s`) from the first to the last, and stays at the last. A route recorded on the road, or drawn in a mapping tool, is a GPX file: its track, else its route, else its waypoints. The phone keeps to the file’s own times, stops included, or goes faster, or keeps one speed throughout:
```gherkin
When the courier app's phone travels the routes/grimmaische-strasse.gpx route
When the courier app's phone travels the routes/grimmaische-strasse.gpx route at 4x
When the courier app's phone travels the routes/grimmaische-strasse.gpx route at 30 km/h
```
`travels` waits for the phone to arrive. `starts traveling` lets the scenario go on while the phone moves, to check what the app does on the way, and a later step waits for it to get there:
```gherkin
When the courier app's phone starts traveling at 30 km/h through:
| 51.3397 | 12.3731 |
| 51.3397 | 12.3760 |
Then within 20s the tracking event stream has an event where:
| event type | courier |
| speedKmh | 30 |
And the courier app's phone reaches the end of its route
```
The phone moves in real time, as a phone on the road does: keep routes short, or play a long one faster. On iOS, the simulator reports the route as CoreLocation reports a moving phone (`simctl location`): its speed, and its course where the simulator gives one (older simulators report none). On Android, the emulators axx starts report it every second, as NMEA sentences that carry the speed and course a plain location fix lacks; a device of your own reports where it really is.
## What the camera sees, and AR
[Section titled “What the camera sees, and AR”](#what-the-camera-sees-and-ar)
An iOS simulator has no camera, and an Android emulator’s sees a room of its own. An app that scans, reads or photographs through it (a QR code, a printed reference, a photo at the door) has nothing to see, and an app that measures or places things with ARKit (RealityKit’s `ARView`) or ARCore has no AR either: the simulator’s ARKit says world tracking is not supported, and an emulator has no Google services for AR. axx plays the camera from what a scenario says it sees, `the courier app's camera sees `, on iOS simulators and on the Android emulators it starts:
* **an image** (PNG or JPEG, and HEIC on iOS), as the phone held upright sees it: a parcel’s label, say;
* **a video** (MP4 or MOV; H.264 on Android), which plays in a loop: a parcel lifted into view;
* **a 3D scene**, a glTF file (`.glb`, or `.gltf` with its buffers and images), seen from where the phone is in it: what the camera sees changes as the phone moves (below), and an app’s AR finds the scene’s surfaces and objects where they are.
```gherkin
Given the courier app's camera sees labels/label-PX-IOS-9621.png
And the courier app is launched
And the "Courier ID" field in the courier app is filled with "CR-LEJ-85"
And the "PIN" field in the courier app is filled with "${sys:couriers.pin}"
And the "Sign in" button is tapped in the courier app
When the "Scan label" button is tapped in the courier app
Then the courier app shows "Ida Lorenz"
```
On a simulator, the app’s capture session takes the camera as its input, as on a phone (`AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)`), and gets its frames, 30 a second, 1920 by 1080, the sensor’s way up as an iPhone’s:
* a **video data output** gets them in the pixel format it asks for (BGRA, or YCbCr 4:2:0), turned as its connection says, for Vision and the like;
* a **metadata output** gets the codes in them, QR codes and barcodes, read with Vision;
* a **photo output** takes a photo of the frame, a JPEG (or HEIC) marked to be turned upright as an iPhone’s is;
* a **preview layer** shows them, and its conversions (a code’s place on the screen, a point of interest) go by them;
* the camera answers what an app sets (focus, exposure, zoom, torch, HDR) as a phone’s does, without changing what it sees.
The app asks for the camera as it would on a phone; `permissions | camera` (`CAMERA` on Android) grants it from the start. A scenario taps what an AR app’s camera shows by its name in the scene:
```gherkin
Given the courier app's camera sees scenes/parcel-on-porch.glb
And the courier app is opened with the "parcels-courier://deliveries/PX-IOS-9581" link
And the "Measure parcel" button is tapped in the courier app
When the "front left corner" in the courier app's camera view is tapped
And the "front right corner" in the courier app's camera view is tapped
Then the courier app shows "60 cm"
```
The scene is where the phone is and what it sees:
* **The phone** is the scene’s camera, or the camera named `phone` when it has several: where it stands, held upright, looking along its −Z, its +Y the top of the phone (a glTF camera’s own axes). The phone’s camera is an iPhone’s wide camera (about 62° across, the picture filling the view), or the Android emulator’s (its sensor 1280 by 960, about 53° across held upright); the scene’s camera gives only where it is and which way it looks.
* **Objects** a scenario names are the scene’s top-level nodes with a name, but for its cameras and its furniture: nodes named with a leading `_`, like `_road` or `_porch`.
* **A tap on an object** lands on its middle, or on its `_tap` child, a node that marks where a tap on it lands: a corner guard’s `_tap` is the parcel’s corner it guards. The app’s AR finds the scene’s surfaces where they are, and a raycast that hits an object lands where a tap on it lands, so what an app measures between two objects is their distance in the scene, whatever the phone’s place.
* **What is drawn** is the scene’s triangle meshes, each node’s transform (its matrix, or its translation, rotation and scale), and its materials’ base color, a color and an image (PNG or JPEG), lit by a soft sky and the sun. Points and lines are left out, as are skins and morph targets; an animation is a path the phone can travel (below), and the scene’s objects stay still.
A tool like Blender exports a scene as glTF; a small program writes one too, as the parcels example’s [`make-scenes.go`](https://github.com/nimbusxr/axx/blob/main/examples/parcels/acceptance/scenes/make-scenes.go) does. The step reads the scene, and fails on one it cannot play: no camera, two cameras none named `phone`, two objects of one name. A running app starts again, to see it; the step before the app is launched gives the scene from the start.
### Moving the phone
[Section titled “Moving the phone”](#moving-the-phone)
The phone moves through the scene as the person holding it does, and the app’s camera, and its AR, see the scene from wherever it is:
```gherkin
Given the courier app's camera sees scenes/parcel-on-porch.glb
And the courier app's phone moves to the scene's "by the van" camera
And the courier app is opened with the "parcels-courier://deliveries/PX-IOS-9582" link
And the "Measure parcel" button is tapped in the courier app
When the courier app's phone travels the scene's "up the garden path" path
And the courier app's phone moves 1 m forward
And the courier app's phone turns 40 degrees down
And the "front left corner" in the courier app's camera view is tapped
```
* **A place the scene names** is one of its cameras: `moves to the scene's "by the van" camera` puts the phone where that camera is, looking the way it looks, at once.
* **A path** is one of the scene’s animations that moves a camera, the phone’s or another one it follows: `travels` carries the phone along it as it was recorded, and waits for it to arrive; `starts traveling` lets the scenario go on while it moves, and `the courier app's phone reaches the end of its route` waits for it. `at 2x` goes twice as fast; `at 1.4 m/s` keeps one speed along the way.
* **A step** moves the phone at a walking pace, still facing the same way: `forward` and `back` along the ground the way it looks (where its top points, when it looks straight down), `left` and `right` across it, `up` and `down`.
* **A turn** turns it where it stands, as the person holding it turns: `left` and `right` about the upright, keeping its tilt; `up` and `down` tilt it.
The phone stays where a step leaves it, the app started again or not: a step before the app is launched puts the phone where the app starts.
On a simulator, axx loads a library of its own into the app when it starts, built once with Xcode into axx’s cache: it plays the camera for the app’s capture sessions; from a scene, it also makes world tracking supported, runs the session’s frames from the scene, draws the camera’s picture behind the `ARView`, and answers RealityKit’s raycasts. The app is the app you ship, unchanged; the library plays the camera and ARKit, which a simulator has not got. It plays apps that show AR with RealityKit’s `ARView`; an `ARSCNView` (SceneKit) app is not played yet, nor the system’s own camera screen (`UIImagePickerController` with the `.camera` source), which an app that checks `isSourceTypeAvailable(.camera)` does not offer on a simulator. It leans on ARKit’s internals, so each Xcode is checked against it: it is checked with Xcode 27.0, on the iOS 27.0 and 18.1 simulators. A real iPhone’s camera, through an Appium server, sees what is in front of it: the step is for simulators.
On an Android emulator, its own back camera shows what the camera sees, to any app (CameraX, Camera2, the system’s camera app): axx starts its emulators with the camera that renders the AVD’s environment (`-camera-back environment`, from the AVD’s `environment.ini`, which axx writes before each one starts), and sets it for each scenario through the emulator’s control service: a picture, a video, or the scene, seen from where axx puts the phone in it. The emulator shows a picture off its middle, so axx lays out a picture, and a video frame by frame, so that the camera sees all of it, aspect-filled into its 4:3 sensor held upright; it re-encodes an H.264 video with Cisco’s OpenH264, which it downloads the first time, as it does for desktop videos (OpenH264 Video Codec provided by Cisco Systems, Inc.). An emulator started from a quick-boot snapshot keeps the camera the snapshot was saved with: axx starts it afresh, cold, the first time a scenario’s camera sees something, unless the snapshot was saved with `hw.camera.back=environment` in the AVD’s `config.ini`, as the parcels example’s [`create-emulator.sh`](https://github.com/nimbusxr/axx/blob/main/examples/parcels/courier/android/create-emulator.sh) saves it.
An Android app’s AR is ARCore’s. In a debuggable app that has ARCore, axx attaches an agent of its own as the app starts, which answers the app’s ARCore calls from the scene: a session that runs, its camera at the phone’s pose, the emulator’s camera’s picture in the texture the app gave ARCore, filling the view as ARCore fills it, and hit tests that land on the scene’s surfaces, and on a named object where a tap on it lands. It answers the session, its frames and camera, hit results, planes and points, anchors and light estimates; depth, cloud anchors, geospatial and recording are not played. axx builds the agent once into its cache, with the SDK’s NDK and build-tools and a JDK, as the models-android pack builds its own, and attaches both when a scenario needs both. An app that is not debuggable runs its ARCore as it is. A real phone’s camera, through an Appium server, sees what is in front of it: the step is for emulators.
## Traces and videos
[Section titled “Traces and videos”](#traces-and-videos)
Ask for a **trace** of each app, and a scenario keeps one in `.axx/mobile/traces`: a page with its screen after each step, and its controls where it failed. A trace waits, after every step, for the screen’s next frame (about a tenth of a second): kept for failed scenarios only, it still slows every step a little, so it is off until you ask.
A **video** is the scenario’s phones as they ran, each tap and swipe drawn where the finger was, with the scenario’s steps beside them: each one to run, running, passed, failed (its error under it) or skipped, and the lines the running step logs, so what happens off the screen shows too (a call to a service, a row in a database). It opens on its first frame for a moment, the steps from the first (those that ran before the app had a screen, such as its registration, among them), and its last frame shows for two seconds, the steps as they ended. It is an MP4 file (H.264) in `.axx/mobile/videos` that plays wherever videos play, with a chapter for each step; a failed scenario’s is attached to the report too. The run keeps one more, `run.mp4`: its kept scenarios one after another, each after a card with its name and whether it passed, and a chapter for each. Every phone has the same place in a video, so an iOS scenario and an Android one are in the same `run.mp4`; a scenario that drives two apps shows both phones, side by side. QuickTime Player, VLC, IINA and mpv list the chapters; a browser plays the video without them.
The screens come from the drivers’ streams of them: WebDriverAgent’s on iOS, the UiAutomator2 server’s on Android, ten frames a second at half the screen’s size. A farm’s device, driven through `appium`, streams none: its scenarios keep neither.
axx.yaml
```yaml
packs:
mobile-core:
traces: failed # always, failed or never (the default)
videos: failed # always, failed or never (the default)
```
Videos are encoded with Cisco’s OpenH264. OpenH264 Video Codec provided by Cisco Systems, Inc. The first time a scenario keeps a video, axx downloads Cisco’s build for your OS from Cisco (about half a megabyte), checks it, and keeps it in its cache; `videos: never`, the default, turns it off, and axx then downloads nothing. Cisco’s terms are in axx’s `THIRD_PARTY_NOTICES.md`.
## In CI
[Section titled “In CI”](#in-ci)
**Android:** Linux runners start emulators with KVM, and draw them without a GPU. The parcels example’s workflow does it this way, with the Android SDK the runner has:
```yaml
- name: KVM, for the emulator
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- run: ./create-emulator.sh # sdkmanager and avdmanager: the device the features name
- run: axx run --tags @android
```
`create-emulator.sh` saves the device’s boot snapshot with the camera axx plays (`hw.camera.back=environment` in its `config.ini`), so that the emulators axx starts from it are ready for what a scenario’s camera sees. It gives the device a front camera too (`hw.camera.front=emulated`): the system image says its phone has one, and on an AVD without, CameraX looks for it for some 6 seconds before an app’s camera shows. An emulator draws its camera without a GPU too, slower than a Mac’s: it takes seconds to show its first frame, which a scenario that reads what the camera sees gives time (`within 30s`). `packs.mobile-android.emulatorArgs` passes more arguments to the emulators, and `bootTimeout` gives a slow machine longer than 3 minutes to boot one. A feature too long for one runner, whose emulator runs a scenario at a time, [shares a run](/next/guides/run-in-ci/#share-a-run-between-runners) with others: `axx run --shard 1/2` on one, `--shard 2/2` on another.
**iOS:** macOS runners have Xcode and its simulators. A new machine’s first simulator boot builds the iOS runtime’s shared cache, which takes over ten minutes on GitHub’s runners, and every hosted run is a new machine: keep that cache between runs, with what axx downloads, keyed by the macOS and runtime builds it belongs to. Never keep a simulator: its keychain belongs to the machine it was made on.
```yaml
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- id: ios
run: |
runtime=$(xcrun simctl list runtimes -j | jq -r '[.runtimes[] | select(.platform == "iOS" and .isAvailable)] | sort_by(.version | split(".") | map(tonumber)) | last | .buildversion')
echo "key=ios-$(sw_vers -buildVersion)-$runtime" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v6
with:
path: |
~/Library/Developer/CoreSimulator/Caches/dyld
~/Library/Caches/axx/mobile
!~/Library/Caches/axx/mobile/simulators
key: ${{ steps.ios.outputs.key }}
- run: xcodebuild -project Courier.xcodeproj -target Courier -configuration Debug -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO SYMROOT=build
- run: axx run --tags @ios
```
With the cache, a run sets its simulators up in a minute or two; without it, the first scenario’s steps need over ten minutes (`run.timeouts: {step: 20m}` in `axx.yaml` gives them that; `packs.mobile-ios.bootTimeout`, 20 minutes by default, bounds a simulator’s boot). A simulator on a hosted runner stays slow for a few minutes after it boots: give checks there `within` to spare. GitHub’s macOS runners have no Docker: when the services your scenarios talk to run in containers, run them elsewhere, or run the iOS scenarios on a Mac that has them.
See the [app-core](/next/references/packs/app-core/), [mobile-core](/next/references/packs/mobile-core/), [mobile-android](/next/references/packs/mobile-android/) and [mobile-ios](/next/references/packs/mobile-ios/) references for every step.
# Seed and check Redis
> Seed keys into Redis or Valkey, Dragonfly, KeyDB and Garnet, and check the keys your services write there, by their value, their JSON properties or hash fields, or that they are gone.
Services keep what they are asked for often in Redis: a cached view, an idempotency key, a session. The `redis` pack seeds keys before a scenario acts, and checks what your service wrote, or that it dropped a key it had to drop. It works with every server that speaks Redis’s protocol: Redis itself, Valkey, Dragonfly, KeyDB and Garnet.
```gherkin
Scenario: A scan drops the parcel's cached tracking view
Given a seeds/cache-dropped.yaml db seed
And a redis/tracking-PX-RDS-9603.yaml redis seed
When a message is published to the depots/LEJ/scans mqtt topic:
"""
{"scanId": "SC-9603-1", "parcelRef": "PX-RDS-9603", "status": "OUT_FOR_DELIVERY"}
"""
Then within 20s the tracking:PX-RDS-9603 redis key does not exist
```
Add the pack to the project with `axx pack add redis` ([Choose packs](/next/guides/use-packs/)).
## Register the server
[Section titled “Register the server”](#register-the-server)
Register the server once, usually in the `Background`:
```gherkin
Background:
Given the cache redis server with the following properties:
| url | redis://${sys:local.host}:6379/0 |
```
The `url` is `redis://user:password@host:6379/0`, or `rediss://` for TLS. A `database` row picks another database than the URL’s. A password, in the URL or from `${env:..}`, is masked in logs and failures.
## Seed keys
[Section titled “Seed keys”](#seed-keys)
A seed file maps each key to what it holds, and optionally how long it lasts:
redis/tracking-PX-RDS-9602.yaml
```yaml
"tracking:PX-RDS-9602":
value: {"parcelRef": "PX-RDS-9602", "status": "OUT_FOR_DELIVERY", "lastLocation": "Leipzig"}
ttl: 10m
"shop:hawthorn-home":
hash: {name: Hawthorn Home, tier: gold}
"depot:LEJ:sorting":
list: [PX-RDS-9601, PX-RDS-9602]
"parcels:express":
set: [PX-RDS-9611, PX-RDS-9612]
"couriers:deliveries":
sorted set: {CR-LEJ-12: 42, CR-LEJ-7: 17}
```
```gherkin
Given a redis/tracking-PX-RDS-9602.yaml redis seed
```
* **What a key holds:** `value` is text, or JSON for an object or an array. The other kinds are `hash`, `list`, `set` and `sorted set`, whose members map to their scores.
* **Replacing:** a seed replaces any key of the same name, so a scenario starts from what its seed says.
* **`ttl`:** a duration like `10m`, after which the key expires.
## Check keys
[Section titled “Check keys”](#check-keys)
```gherkin
Then the idempotency:rowanberry-crafts:order-8812 redis key has the value 'PX-RDS-9604'
And the tracking:PX-RDS-9601 redis key has the following properties:
| parcelRef | PX-RDS-9601 |
| status | REGISTERED |
And within 20s the tracking:PX-RDS-9603 redis key does not exist
```
* `has the value` compares a string key’s text, all of it.
* `has the following properties:` reads the key as JSON, whatever its type:
* a string holding JSON is that JSON;
* a hash is an object of its fields;
* a list is an array;
* a set is a sorted array;
* a sorted set is an object of its members and their scores.
Each row is a path into it and the value it has, as in the other JSON property steps: `null` for null and `undefined` for absent.
* `does not exist` checks that a key is gone, for a service that deletes it or lets it expire.
Every check waits for the key to be as it says: 10 seconds, or the time `within` gives.
## Keep scenarios apart
[Section titled “Keep scenarios apart”](#keep-scenarios-apart)
Scenarios run in parallel against the same server, so each seeds and checks keys of its own, named after data unique to it, such as a parcel reference. An `axx lint` rule can keep seeded key names unique across files:
axx.yaml
```yaml
lint:
rules:
- name: Redis keys in seeds
filePatterns: ["redis/*.yaml"]
regex: '^"([^"]+)":'
validation: cross-file-unique
```
See the [redis pack’s reference](/next/references/packs/redis/) for every step.
# Test web apps
> Use your web app in real browsers the way people do (Chromium, Firefox, WebKit, Chrome or Edge, on a desktop or a phone), driven by Playwright on your machine, and check in the same scenario what your service did.
Many acceptance criteria describe what a person does on a page: a shop fills in a form, clicks a button and sees its parcel registered. The `web-core` pack does exactly that in real browsers, driven by [Playwright](https://playwright.dev). A scenario can use every other pack too, so it also checks what your service did with the form: the row it stored, the event it published.
```gherkin
Scenario: A parcel registered in the portal is stored and announced
When the "/parcels/new" page is opened
And the "Shop account" field is filled with "fjord-outdoor"
And the "Parcel reference" field is filled with "PX-WEB-5001"
And the "Weight (grams)" field is filled with "1200"
And the "Recipient name" field is filled with "Anna Weber"
And the "Postcode" field is filled with "10115"
And "Germany" is chosen in the "Destination country" field
And the "Leave with a neighbour" checkbox is unchecked
And the "Register the parcel" button is clicked
Then the "/parcels/PX-WEB-5001" page is shown
And the page shows "Parcel PX-WEB-5001 registered"
And a selection of rows is retrieved from the parcels.parcels table where:
| reference | PX-WEB-5001 |
And the selection has 1 row
And the parcel-events kafka event named registered key is PX-WEB-5001
```
Add the pack to the project with `axx pack add web-core` ([Choose packs](/next/guides/use-packs/)). This guide sets it up; the others go further:
* [Work with pages](/next/guides/web-pages/): forms, clicks and gestures, keys, dialogs, browser tabs, frames, downloads, the browser’s clock, its connection and devices.
* [Check what pages show](/next/guides/web-checks/): text, elements and their state, tables, counts, files, script errors and requests.
* [Sign in to web apps](/next/guides/web-sign-in/): the sign-in flow, or a session to start with, and secrets.
* [Compare screenshots](/next/guides/web-screenshots/): pages that look as designed, each platform with its own screenshots (the `web-screenshots` pack).
* [Check accessibility](/next/guides/web-accessibility/): WCAG audits with axe-core, and what a screen reader reads (the `web-a11y` pack).
* [Control what pages fetch](/next/guides/web-network/): requests that fail, answer with a file or take their time, a slow connection, recordings, and websocket messages (the `web-network` pack).
* [Audit pages with Lighthouse](/next/guides/web-lighthouse/): performance, accessibility, best practices and SEO scores, and the Core Web Vitals (the `web-lighthouse` pack).
* [Measure JavaScript coverage](/next/guides/web-coverage/): how much of the web apps’ JavaScript the scenarios run, as lcov and Istanbul reports (the `web-coverage` pack).
* [Watch and debug browsers](/next/guides/watch-web-browsers/): see the browsers as they go, pause a scenario in Playwright’s Inspector, pick elements and record steps, traces and videos.
* [How the web-core pack works](/next/explanations/web-browsers/): the browsers, isolation, waiting, names and selectors.
## The browsers
[Section titled “The browsers”](#the-browsers)
The browsers run on the machine that runs Axx, as in any Playwright project: Chromium, Firefox and WebKit, the builds of Playwright 1.62.1, the version the pack drives. The pack downloads each one the first time a scenario uses it, and keeps it where Playwright keeps its browsers, for the next runs and for other Playwright projects of that version: `~/Library/Caches/ms-playwright` on macOS, `~/.cache/ms-playwright` on Linux, `%LOCALAPPDATA%\ms-playwright` on Windows (`PLAYWRIGHT_BROWSERS_PATH` moves it). The engines `chrome` and `msedge` are the Google Chrome and Microsoft Edge installed on the machine. Chromium is the whole browser, with a window or without one, so that a run behaves the same whether you watch it or not.
The browsers use your web apps at the addresses you would: `localhost` and the port their service listens on, whether it runs in Compose, publishing its port, or on your machine, from your IDE or with `axx run --debug` ([Manage the services under test](/next/guides/manage-services/)).
### In CI
[Section titled “In CI”](#in-ci)
On Linux, the browsers need system libraries that a bare machine or a slim image lacks. Install them before the run, with Playwright’s command for the engines you use:
.github/workflows/acceptance.yml
```yaml
- run: sudo npx -y playwright@1.62.1 install-deps chromium
```
Or run the job in Playwright’s image, `mcr.microsoft.com/playwright:v1.62.1-noble`, which has them. Keep Playwright’s folder of browsers in the CI cache to save the download. When a library is missing, the step that opens the first page says which command installs them.
## Register the web app
[Section titled “Register the web app”](#register-the-web-app)
Register the app with its address:
```gherkin
Background:
Given the parcels web app with the following properties:
| url | http://localhost:8400/portal |
```
| Property | |
| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | where the browser finds the app: page paths are below it (required) |
| `engine` | `chromium` (the default), `firefox`, `webkit`, or `chrome` and `msedge`: the Google Chrome and Microsoft Edge installed on the machine |
| `device` | a device Playwright knows, like `iPhone 15`, `Pixel 7` or `iPad Pro 11`: its screen size, user agent and touch screen |
| `viewport` | the size of the page, like `1280x720` (the default) |
| `locale` | the browser’s language, like `de-DE` (`en-US` by default, whatever the machine’s) |
| `timezone` | the browser’s time zone, like `Europe/Berlin` (`UTC` by default, whatever the machine’s) |
| `color scheme` | `light`, `dark` or `no-preference`: what `prefers-color-scheme` says |
| `reduced motion` | `reduce` or `no-preference`: what `prefers-reduced-motion` says |
| `media` | `screen` (the default) or `print`: the pages as they print, with their print styles |
| `location` | where the browser says it is: a latitude and a longitude, like `52.5200, 13.4050` |
| `permissions` | permissions the app has, like `notifications, clipboard-read` |
| `user agent` | the user agent the browser sends |
| `tls.verify` | `false` accepts the app’s certificate whatever it is, for test environments with their own |
| `cookie.`, `header.`, `local storage.`, `session storage.`, `username`, `password` | a session to start with ([Sign in to web apps](/next/guides/web-sign-in/#start-with-a-session)) |
To run the same features in another engine, make it a property: `| engine | ${sys:browser.engine:-chromium} |`, then `axx run -D browser.engine=webkit`. A phone is another web app of the same address:
```gherkin
Scenario: Shops register parcels from their phones too
Given the phone web app with the following properties:
| url | http://localhost:8400/portal |
| engine | webkit |
| device | iPhone 15 |
When the "/quote" page of the phone web app is opened
And the "Menu" button is tapped
And the "Register a parcel" link is tapped
Then the "/parcels/new" page is shown
```
`the "/quote" page is opened` opens a page of the app, and the steps after it act on that page. With several web apps registered, `the "/quote" page of the parcels web app is opened` names the one.
## Find things the way people do
[Section titled “Find things the way people do”](#find-things-the-way-people-do)
Steps name what people see on the page:
* **Fields** by their label: `the "Weight (grams)" field`. A field without a label is found by its placeholder.
* **Drop-downs** are fields too; `"Germany" is chosen in the "Destination country" field` picks an option by its text.
* **Radio buttons** by their label: `the "Express" option is chosen`. **Checkboxes** the same way: `the "Insure this parcel" checkbox is checked`.
* **Buttons** by their name (their text, or their label), **links** by their text, **tabs** in a row of tabs on the page by their name, and **menu items** in an open menu.
* **Elements**, anything else: by their text, their label, an image’s alternative text or a title: `the "Ella Brandt" element is double-clicked`.
* **Text** anywhere on the page: `the page shows "6.90 EUR"`, part of what the page shows, where case matters.
Names match exactly. When nothing on the page has the name, the step says what the page has instead:
```text
No field named "Weight" on the page; 3 fields:
"Weight (grams)"
"Destination country"
"Postcode"
```
A field that people can use but a step cannot find usually has no label. Give it one: the page becomes more accessible too. Steps look inside frames as well, such as an embedded widget or a payment form: what people see on the page, the steps find, whichever frame it is in.
### Selectors, when names are not enough
[Section titled “Selectors, when names are not enough”](#selectors-when-names-are-not-enough)
Where a name is not enough, or not there, a step takes a selector instead, in the same place: a CSS selector, an XPath, or a test id, the `data-testid` attribute of your markup.
```gherkin
Then the "testid=parcel-count" element shows "25 parcels"
And the page shows 20 "testid=parcel-row" elements
And the "xpath=//tbody/tr[20]/td[1]" element shows "PX-WEB-5220"
And the "css=.quote-result" element shows "6.90 EUR"
```
`css=`, `xpath=` and `testid=` start a selector; anything else is a name. `packs.web-core.testIdAttribute` in `axx.yaml` changes the attribute test ids are in. Prefer names where they work: they read like the page, they keep working when the markup changes, and a page people can find things on is one they can use.
## Waiting
[Section titled “Waiting”](#waiting)
Pages change after the fact: a price arrives from a pricing service, a message goes away. Every step waits for what it needs, so there is never a reason to sleep:
* An action waits for its element to be there and ready: visible, enabled, not moving.
* A check waits for the page to show what it expects: 10 seconds, or the time `within {duration}` gives.
```gherkin
Scenario: A quote takes a moment
When the "/quote" page is opened
And the "Weight (grams)" field is filled with "1200"
And "Germany" is chosen in the "Destination country" field
And the "Postcode" field is filled with "10115"
And the "Get a quote" button is clicked
Then within 5s the page shows "Price: 6.90 EUR"
And the page does not show "Calculating"
```
## Scenarios run side by side
[Section titled “Scenarios run side by side”](#scenarios-run-side-by-side)
Every scenario has a browser context of its own, and one for each web app it registers: its own cookies, storage and pages. Scenarios run in parallel like any others, and a shop signed in in one scenario is never signed in in another. Like all parallel scenarios, give each one its own data, such as a parcel reference ([Isolate test data](/next/guides/isolate-test-data/)).
The [`parcels` example](https://github.com/nimbusxr/axx/tree/main/examples/parcels) has a shop portal and its features: `shop-portal.feature`, `portal-parcels.feature`, `portal-planning.feature` and `portal-tracking.feature`.
# Test WebSockets
> Open WebSocket connections to your services, send messages on them, and check the messages they receive and the code they close with.
A WebSocket keeps a connection open both ways: a page follows a parcel’s scans as they happen, a courier’s app gets its next stop. The `websocket` pack opens connections to your services, sends messages on them and checks what comes back, whatever made the service send it: a Kafka event, a row, a request.
```gherkin
Scenario: The tracking page hears that its parcel is out for delivery
Given a seeds/live-websocket.yaml db seed
And the tracking websocket with the following properties:
| url | ws://localhost:8400/portal/track/PX-LIV-5701/live |
And a message is sent to the tracking websocket:
"""
{"follow": "PX-LIV-5701"}
"""
And a depot-scans kafka event
And the depot-scans kafka event key is PX-LIV-5701
And the depot-scans kafka event payload is a kafka/scan-out-for-delivery.json resource
When the depot-scans kafka event is published using schema schemas/depot-scan.avsc
Then within 20s the tracking websocket received a message where:
| reference | PX-LIV-5701 |
| status | OUT_FOR_DELIVERY |
```
Add the pack to the project with `axx pack add websocket` ([Choose packs](/next/guides/use-packs/)). To check what a page in a browser sends and receives on its WebSockets, use the web packs instead ([Web network](/next/guides/web-network/)).
## Open a connection
[Section titled “Open a connection”](#open-a-connection)
Register a connection under a name. It opens there and then, and belongs to the scenario, which closes it when it ends:
```gherkin
Given the tracking websocket with the following properties:
| url | wss://parcels.example.com/portal/track/PX-LIV-5701/live |
| header.Authorization | Bearer ${env:SHOP_TOKEN} |
| subprotocol | parcels.v1 |
```
| Property | What it is |
| --------------- | ------------------------------------------------------------------------------------------------------------------- |
| `url` | The `ws://` or `wss://` URL (required). |
| `header.` | A header of the opening request, such as a token. |
| `subprotocol` | The subprotocol to ask for. A server that picks none is still accepted. |
| `asyncapi` | The AsyncAPI document the messages both ways follow ([Validate against AsyncAPI](/next/guides/validate-asyncapi/)). |
A connection the server refuses fails the step, with what the server answered. `${env:..}` values, such as a token, are masked in logs and failures.
## Send messages
[Section titled “Send messages”](#send-messages)
Send a text message from the doc string, or from a file of the project:
```gherkin
When a message is sent to the tracking websocket:
"""
{"follow": "PX-LIV-5701"}
"""
And the tracking/follow.json message is sent to the tracking websocket
```
## Check what it received
[Section titled “Check what it received”](#check-what-it-received)
A check looks at every message the connection received since it opened, and waits for one that meets it: 10 seconds, or the time `within` gives.
```gherkin
Then within 20s the tracking websocket received a message where:
| reference | PX-LIV-5701 |
| status | OUT_FOR_DELIVERY |
| location | Leipzig |
And the tracking websocket received a message containing 'PX-LIV-5701'
```
* `received a message where:` reads each message as JSON: a path and the value as text, `null` for null and `undefined` for absent, like the message checks of queues and topics.
* `received a message containing {string}` looks for text in any message.
A failed check lists the messages the connection received. A binary message shows as its size.
## Check how it closed
[Section titled “Check how it closed”](#check-how-it-closed)
When a server closes a connection, a check still waiting for a message fails at once, and says why. Check the code the server closed it with:
```gherkin
Scenario: The tracking websocket closes a connection that does not say which parcel it follows
Given the tracking websocket with the following properties:
| url | ws://localhost:8400/portal/track/PX-LIV-5702/live |
When a message is sent to the tracking websocket:
"""
{"parcel": "PX-LIV-5702"}
"""
Then the tracking websocket was closed with code 1008
```
The check waits for the connection to close, like the others.
## Keep scenarios apart
[Section titled “Keep scenarios apart”](#keep-scenarios-apart)
A connection belongs to its scenario: another scenario running in parallel never sees it or its messages, and it closes when its scenario ends. What a service sends on a connection often depends on shared data, so follow data unique to the scenario, such as a parcel of its own ([Isolate test data](/next/guides/isolate-test-data/)).
See the [websocket pack’s reference](/next/references/packs/websocket/) for every step.
# Choose packs
> Pick the packs a project uses with axx-packs.yaml and axx pack add, remove, list and update.
A **pack** is a set of steps. Axx publishes `rest`, `mock` (mocked dependencies, and mocked AI models: [Test AI features](/next/guides/test-ai-features/)), `grpc` ([Call gRPC services](/next/guides/test-grpc/)), `jsonrpc` ([Call JSON-RPC services](/next/guides/test-jsonrpc/)), `graphql` ([Call GraphQL services](/next/guides/test-graphql/)), `mcp` ([Test MCP servers](/next/guides/test-mcp/)), `a2a` ([Test A2A agents](/next/guides/test-a2a/)), `sql`, `mongo`, `redis` ([Seed and check Redis](/next/guides/test-redis/)), `kafka`, `amqp`, `mqtt`, `nats` ([Test message brokers](/next/guides/test-message-brokers/)), `websocket` ([Test WebSockets](/next/guides/test-websockets/)), `sse` ([Test event streams](/next/guides/test-event-streams/)), `asyncapi` (their messages checked against your AsyncAPI document: [Validate against AsyncAPI](/next/guides/validate-asyncapi/)), `logs`, `files` (the files your services write: [Check files](/next/guides/check-files/)), `mail` ([Check emails](/next/guides/check-emails/)), `cli` (commands: [Run commands](/next/guides/run-commands/)) and `web-core` (web apps in real browsers: [Test web apps](/next/guides/test-web-apps/)), with the packs that build on it: `web-screenshots`, `web-a11y`, `web-network`, `web-lighthouse` and `web-coverage`, `mobile-android` and `mobile-ios` (Android apps on emulators, iOS apps on simulators: [Test mobile apps](/next/guides/test-mobile-apps/)), which build on `mobile-core`, and `desktop-macos`, `desktop-windows` and `desktop-linux` (desktop apps, through the operating system’s accessibility tree), which build on `desktop-core`. It also publishes packs for cloud services, one per service, named after its cloud ([Test cloud services](/next/guides/test-cloud-services/)):
* **AWS:** `aws-s3`, `aws-sqs`, `aws-sns`, `aws-eventbridge` and `aws-dynamodb`, which build on `aws-core`.
* **Google Cloud:** `gcp-storage`, `gcp-pubsub`, `gcp-bigquery` and `gcp-firestore`, which build on `gcp-core`.
* **Azure:** `azure-blob` and `azure-servicebus`.
Each pack’s steps, settings and tools are on its page of the [pack reference](/next/references/packs/). A project can write packs of its own, or use packs other teams publish. Every pack, Axx’s or anyone else’s, is used the same way: the project lists it in `axx-packs.yaml`, next to `axx.yaml`.
## Choose the packs a project uses
[Section titled “Choose the packs a project uses”](#choose-the-packs-a-project-uses)
```console
$ axx pack add rest sql
created axx-packs.yaml
added rest, sql
$ axx pack list
rest axx 46 steps
sql axx 17 steps
mock axx not used
mongo axx not used
...
```
axx-packs.yaml
```yaml
packs:
- rest
- sql
```
Only the listed packs are loaded, so their steps are the only ones `axx validate`, `axx steps` and the skills know about. Listing a pack that builds on another (a web pack such as `web-a11y`, `mobile-android`, `desktop-macos`, or an AWS or Google Cloud pack) loads that one too: `web-core`, `mobile-core` or `desktop-core` (and `app-core`, which they build on), `aws-core` or `gcp-core`. `axx pack remove` takes packs off the list. `axx init` starts the file with `rest`, which its first feature uses. Commit `axx-packs.yaml`.
The first `axx` command that needs the project’s steps prepares Axx with its packs:
```console
$ axx validate
axx: preparing rest, sql (once; cached for later runs)
axx: ready in 31s
2 files, 6 scenarios, 41 steps: ok
```
This happens once for each list of packs. Later runs start immediately. The first time, Axx downloads what it needs, so it takes longer. In CI, keep Axx’s cache between runs ([Run in CI](/next/guides/run-in-ci/#keep-axxs-cache)).
## Add your own packs
[Section titled “Add your own packs”](#add-your-own-packs)
Your own packs are listed the same way, by path or by Go module:
```console
$ axx pack new ./steps # create a pack in this repository
$ axx pack add github.com/team/axx-grpc@v1.2.0 # use a pack published as a Go module
```
[Write custom steps](/next/guides/write-custom-steps/) shows what goes in a pack. When you change a pack in the repository, the next `axx` command prepares Axx with the new version.
Versions of module packs are pinned in `axx-packs.lock` (commit it). `axx pack update` moves them to the latest version, or to the version written in `axx-packs.yaml`, and prepares Axx again.
# Validate against AsyncAPI
> Check every message a scenario sends and every message a check finds against your AsyncAPI document, over Kafka, AMQP, MQTT, NATS, cloud queues and topics, WebSockets and event streams, and relax individual rules for negative tests.
Give a broker’s registration an `asyncapi` property and Axx checks messages against that document:
* **Every message a scenario sends,** before it goes. This catches test data that drifted from the contract.
* **Every message a check finds,** before the check passes. This catches a service that broke the contract.
A message that breaks the document fails the step and names the rule it broke. Your AsyncAPI document stays the contract for your events, as your OpenAPI document is for your requests ([Validate against OpenAPI](/next/guides/validate-openapi/)).
```gherkin
Scenario: A scanner is alerted when it scans a parcel the service does not know
Given the depots mqtt broker with the following properties:
| url | mqtt://localhost:1883 |
| asyncapi | http://localhost:8400/asyncapi.yaml |
When the mqtt/scan-unknown.json message is published to the depots/LEJ/scans mqtt topic
Then within 10s the depots/+/alerts mqtt topic has a message where:
| parcelRef | PX-MQT-8299 |
| problem | unknown parcel |
```
Add the pack to the project with `axx pack add asyncapi` ([Choose packs](/next/guides/use-packs/)). An `asyncapi` row without it fails the registration and says so.
## Turn it on
[Section titled “Turn it on”](#turn-it-on)
`asyncapi` is a URL or a file path (resolved against `resources`), in YAML or JSON. AsyncAPI 3.x and 2.x (2.6 and earlier) are supported, with `$ref`s to other files and URLs, relative to the document that holds them.
Point it at the document your service serves, or at the file in your repository. The row works on every registration that messages go through:
| Registration | Its messages |
| ---------------------------------- | ----------------------------- |
| `the {word} kafka service` | the events of its topics |
| `the {word} amqp broker` | its queues and exchanges |
| `the {word} mqtt broker` | its topics |
| `the {word} nats server` | its subjects and streams |
| `the {word} aws account` | its SQS queues and SNS topics |
| `the {word} gcp project` | its Pub/Sub topics |
| `the {word} service bus namespace` | its queues and topics |
| `the {word} websocket` | the messages both ways |
| `the {word} event stream` | its events |
## How a message finds its place in the document
[Section titled “How a message finds its place in the document”](#how-a-message-finds-its-place-in-the-document)
A message is checked against a **channel**, then one of the channel’s **messages**:
* **Channels, by address.** The channel whose address is where the message goes: a topic, a queue, a subject, or an exchange then its routing key. For a WebSocket or an event stream, it is the path of the URL; a server’s `pathname` may come before it.
* A `{parameter}` stands for any text without a slash: `depots/{depot}/scans` is the channel of `depots/LEJ/scans`, and `tracking.{reference}` of `tracking.PX-NAT-8301`.
* A check on a filter or a stream (`the depots/+/alerts mqtt topic`, `the TRACKING nats stream`) looks for the channel of the topic or subject each message came on.
* When channels name their servers, only the servers of the registration’s protocol count, so one document can describe the same address on Kafka and on MQTT.
* **Messages, by their schemas.** The message’s payload and headers must follow the schemas of one of the channel’s messages. An event stream’s event type picks the message of that name (`scan`, `delivered`).
* When none fits, the failure names the message it comes closest to: the one that declares the most of its properties.
* **Schemas.**
* JSON Schema is draft 7 unless the schema says otherwise, and AsyncAPI’s own schema format is read as JSON Schema.
* Avro payloads (`schemaFormat: application/vnd.apache.avro;version=1.9.0`) are in Avro’s JSON encoding, as the Kafka pack publishes them.
* Payloads are JSON unless their content type says YAML or text.
* A message’s `contentType` (or the document’s `defaultContentType`) is checked against the content type the message has, when it has one.
## Validation levels
[Section titled “Validation levels”](#validation-levels)
Each rule has a key and a level:
| Key | Found when |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `validation.channel.unknown` | the document has no channel for the message |
| `validation.message.contentType` | the message’s content type is not its message’s |
| `validation.message.payload.format` | the payload is not JSON (or YAML) |
| `validation.message.payload.schema.` | the payload breaks a JSON Schema keyword (`required`, `type`, `enum`…), or `avro` its Avro schema |
| `validation.message.headers.schema.` | the headers break a keyword of their schema |
`ERROR` fails the step, `WARN` and `INFO` log the finding, and `IGNORE` drops it. Everything is `ERROR` by default; `FAIL` is an alias of `ERROR`.
A key also sets the keys below it, and the most specific key set wins: `validation.message.payload` relaxes `validation.message.payload.schema.required` too. A key must be one Axx reports, or a prefix of such keys: a misspelled key fails the step (or the run, in `axx.yaml`), and the message names the closest keys.
### Relax a rule in a scenario
[Section titled “Relax a rule in a scenario”](#relax-a-rule-in-a-scenario)
A scenario that sends a broken message on purpose, to check what the service does with it, relaxes only what it breaks:
```gherkin
Scenario: A report without a parcel reference is set aside
Given the AsyncAPI validation levels are:
| validation.message.payload | IGNORE |
When a message is sent to the parcels.label-printed amqp queue:
"""
{"printer": "LEJ-3", "job": "JOB-AMQ-8105"}
"""
Then within 10s the parcels.label-printed.rejected amqp queue has a message where:
| job | JOB-AMQ-8105 |
| header reason | not a label report |
```
`WARN` keeps a known gap visible in the logs without failing: the example’s older printers name themselves in a header, not in the body the contract describes.
```gherkin
Scenario: An older printer reports straight to the service's queue
Given a seeds/printing-queue.yaml db seed
And the AsyncAPI validation levels are:
| validation.message.payload.schema.required | WARN |
When the amqp/label-printed-PX-AMQ-8103.json message is sent to the parcels.label-printed amqp queue with the following properties:
| header printer | DRS-1 |
```
The levels apply to every contract the scenario checks messages against, until it ends.
### Defaults for the whole suite
[Section titled “Defaults for the whole suite”](#defaults-for-the-whole-suite)
`packs.asyncapi.levels` in `axx.yaml` sets the defaults the scenarios’ levels are merged over. Use it for a known gap in a document you do not control:
axx.yaml
```yaml
packs:
asyncapi:
levels:
validation.message.contentType: WARN
```
## Build messages from the document
[Section titled “Build messages from the document”](#build-messages-from-the-document)
A fixture factory builds messages from the payload of an AsyncAPI message, the way it builds request payloads from OpenAPI components ([Fixture factories](/next/guides/fixture-factories/)):
mqtt/scans.factory.yaml
```yaml
factory:
family: json
schema: ../../app/asyncapi.yaml#/components/messages/scan
prototype:
location: Leipzig
identity:
- path: scanId
derive: authored
fixtures:
scan-unknown:
scanId: SC-8299-1
parcelRef: PX-MQT-8299
status: IN_TRANSIT
```
The payload’s `$ref`s into the document’s components resolve, and so do those to other files. An Avro payload is built with the `avro` family, from its `.avsc` file.
See the [asyncapi pack’s reference](/next/references/packs/asyncapi/) for the step and the keys.
# Validate against OpenAPI
> Check every request and response against your OpenAPI document, build payloads from its examples, and relax individual rules for negative tests.
Give a REST service an `openapi` property and Axx validates every request it sends and every response it receives against that document. A violation fails the `When the request is executed` step and names the rule that broke. Your OpenAPI document stays the contract, and the acceptance suite proves the service honors it.
## Turn it on
[Section titled “Turn it on”](#turn-it-on)
```gherkin
Background:
Given the parcels service with the following properties:
| url | http://localhost:8400 |
| openapi | http://localhost:8400/openapi.json |
```
`openapi` is a URL or a file path (resolved against `resources`), in JSON or YAML. OpenAPI 3.0 and 3.1 are supported.
Point it at the document your service serves (`/openapi.json`, `/v3/api-docs`) or at the file in your repository. Do not point it at a third party’s live URL: if it is unreachable, your tests fail for reasons that have nothing to do with your service. Mock third parties instead: the WireMock image that mocks them checks their contracts, with settings of their own ([Mock dependencies](/next/guides/mock-dependencies/#check-the-dependencys-contract)).
## Build payloads from examples
[Section titled “Build payloads from examples”](#build-payloads-from-examples)
Payloads in Gherkin tables get long. Start from an example in the OpenAPI document and change only what the scenario is about:
openapi.yaml (excerpt)
```yaml
paths:
/api/parcels:
post:
requestBody:
content:
application/json:
examples:
Standard parcel:
value:
reference: PX-EXAMPLE-1
sender: shop-example
weightGrams: 1200
serviceLevel: STANDARD
recipient:
name: Ada Lovelace
street: Invalidenstrasse 116
city: Berlin
postcode: "10115"
country: DE
```
```gherkin
Scenario: A shop registers a parcel
Given a POST request to /api/parcels
And a request payload using an application/json content example named 'Standard parcel'
And the request payload properties are:
| reference | PX-REG-1001 |
| weightGrams | 2500 |
When the request is executed
Then the response status code is 201
```
* `a request payload using an application/json content example` takes the operation’s first example; add `named ''` to pick one.
* `a request payload using an application/json empty content template` starts from an empty body instead.
* Examples can use `externalValue` to keep large payloads in their own files.
### Setting values in a payload
[Section titled “Setting values in a payload”](#setting-values-in-a-payload)
Keys are JSONPath expressions (`reference`, `recipient.postcode`, `items[0].sku`). A value keeps the type of the property it replaces, double quotes make it a string, `null` sets JSON null and `undefined` removes the property. The [rest pack reference](/next/references/packs/rest/#restrequestproperties) has the exact rules.
Double-quote values that look like numbers but are strings in the schema, such as postcodes (`"50667"`) or long numeric identifiers, so they stay exact strings.
## Validation levels
[Section titled “Validation levels”](#validation-levels)
Each rule has a level: `ERROR` fails the step, while `WARN`, `INFO` and `IGNORE` let it pass. Everything is `ERROR` by default. `FAIL` is accepted as an alias of `ERROR`.
For a negative test, where you send an invalid request on purpose to check that the service rejects it, relax only the rule you are breaking, only in that scenario:
```gherkin
Scenario: Parcels over 30 kg are refused
Given the OpenAPI validation levels are:
| validation.request.body.schema.maximum | IGNORE |
And a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload properties are:
| reference | PX-REG-1005 |
| weightGrams | 31000 |
When the request is executed
Then the response status code is 400
And the response payload property detail is 'weightGrams must be at most 30000'
```
The document says `weightGrams` is at most 30000, so without the relaxed level the request step would fail on the contract violation, and the scenario could never check how the service answers. Only the `maximum` rule is relaxed; the rest of the body is still checked.
With several REST services, name the one to relax:
```gherkin
Given the OpenAPI validation levels on parcels are:
| validation.request.body.schema.maximum | IGNORE |
```
Levels apply when the request is executed, so a scenario can relax a rule for one request and restore it for the next by setting `ERROR` again.
Common keys:
| Key | Checks |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation.request.body` | the request body against the schema |
| `validation.request.body.schema.` | one schema keyword in the request body (`maximum`, `pattern`, `required`, …) |
| `validation.request.parameter.missing` | required path parameters (query and header parameters have `validation.request.parameter.query.missing` and `validation.request.parameter.header.missing`) |
| `validation.request.security.missing` | required security schemes |
| `validation.response.body` | the response body against the schema |
| `validation.response.body.schema.required` | required properties in the response |
| `validation.response.body.schema.type` | property types in the response |
The failure message of a violated rule includes its key, so you can copy it into the table. A key must be one Axx reports, or a prefix of such keys, like `validation.request.body`: a misspelled key fails the step (or the run, in `axx.yaml`), and the message names the closest keys.
### Defaults for the whole suite
[Section titled “Defaults for the whole suite”](#defaults-for-the-whole-suite)
`openapi.levels` in `axx.yaml` sets suite-wide defaults. Use it for a known gap in a document you do not control, not to silence your own contract:
axx.yaml
```yaml
openapi:
levels:
validation.request.security.missing: IGNORE
```
## OpenAPI 3.1 and `nullable`
[Section titled “OpenAPI 3.1 and nullable”](#openapi-31-and-nullable)
OpenAPI 3.1 replaced `nullable: true` with JSON Schema type unions. In a document that declares `openapi: 3.1.0`, a schema that uses `nullable` does not compile, so every request or response validated against it fails with a `...schema.processingError`, whatever its values:
```yaml
# openapi: 3.1.0
nickname:
type: [string, "null"] # not: type: string + nullable: true
```
`nullable` is still correct in `openapi: 3.0.x` documents.
## The other side: your dependencies’ contracts
[Section titled “The other side: your dependencies’ contracts”](#the-other-side-your-dependencies-contracts)
This page covers your service’s own contract. The services your service calls have contracts too: run their WireMock mocks with the OpenAPI validation extension, and every call your service makes and every stubbed answer is checked against the dependency’s document. Those checks have levels of their own; `the OpenAPI validation levels are:` never relaxes them. See [Mock dependencies](/next/guides/mock-dependencies/#check-the-dependencys-contract).
# Watch a run
> See what scenarios do as they do it, web and mobile alike, with browsers and devices in windows on your screen, one scenario at a time, slowed down to follow.
A run you watch shows what its scenarios do as they do it. Browsers open in windows on your screen, an iOS simulator in Device Hub, and an Android emulator in its own window. One switch covers every kind of app a scenario uses:
```sh
axx run --watch # one scenario at a time, on screen
axx run --watch --slowdown 500ms # with a pause after every action, to follow it
```
* `--watch` runs one scenario at a time, unless `--workers` (or `run.workers`) says otherwise.
* `--slowdown` pauses after every action: a click, a tap, a typed field, a launch.
* Watching needs a screen. On a machine without one, like a CI runner, run without it.
The same settings work as `run.watch` and `run.slowdown` in `axx.yaml`. A profile keeps them for when you want them:
axx.yaml
```yaml
profiles:
watch:
run:
watch: true
slowdown: 300ms
```
`axx run --profile watch` runs with it, and with another profile after a comma, like `--profile ios,watch`, it watches that profile’s run ([Select by what scenarios use](/next/guides/tags-and-filtering/#select-by-what-scenarios-use)). `axx.local.yaml` can hold the settings for you alone. In your editor, *Watch* (next to *Run* and *Debug*) runs a scenario this way ([Set up your editor](/next/guides/set-up-your-editor/#watch-a-run)).
## What you see
[Section titled “What you see”](#what-you-see)
| App | While you watch |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Web ([`web-core`](/next/guides/test-web-apps/)) | Each browser in a window you can use as your own: click in it, and open DevTools on its page, console and network. `packs.web-core.watch` shows the browsers alone ([Watch and debug browsers](/next/guides/watch-web-browsers/)). |
| iOS ([`mobile-ios`](/next/guides/test-mobile-apps/)) | The simulator in Device Hub (the Simulator app before Xcode 27), opened on it before the scenario starts. The simulator is in Xcode’s own set, named like `axx iPhone 17, iOS 27.0 (1)`, and stays booted after the run, so the next watched run starts at once ([keep](/next/guides/test-mobile-apps/#keep-simulators-warm)). |
| Android ([`mobile-android`](/next/guides/test-mobile-apps/)) | The emulator in its window. |
Each scenario still has its device to itself, and its app reset. Watching changes what you see, not where a scenario starts from.
## Stop where it matters
[Section titled “Stop where it matters”](#stop-where-it-matters)
Watching doesn’t pause. To stop a web scenario before a step, or where it fails, pause it in Playwright’s Inspector ([Watch and debug browsers](/next/guides/watch-web-browsers/#pause-a-scenario)). What a failed scenario keeps (screenshots, traces, videos, and the page or screen it was on) is in the [report](/next/guides/reports/).
# Watch and debug browsers
> Watch the browsers of a run as they go, pause a scenario in Playwright's Inspector before a step or where it failed, and read the trace, video and screenshot a failed scenario keeps.
The browsers run on your machine, and you can see them: live while a run goes, and afterwards, in what a failed scenario keeps.
## Watch a run
[Section titled “Watch a run”](#watch-a-run)
`axx run --watch` shows what the scenarios do: their browsers open in windows on your screen as they go, one scenario at a time, and their devices too ([Watch a run](/next/guides/watch-runs/)). The windows are yours to use: click in them, and open DevTools to look at the page, its console and its network.
```sh
axx run --watch --slowdown 300ms # wait after every action, to follow what happens
```
* `packs.web-core.watch: true` shows the browsers alone, and `packs.web-core.slowdown` sets their own pace.
* Watching needs a screen: on a Linux machine without one, such as a CI runner, run without it.
In the IntelliJ plugin and the VS Code extension, *Watch* runs scenarios this way, one at a time, with a slowdown of 300ms unless you set another ([Set up your editor](/next/guides/set-up-your-editor/#watch-a-run)). Watching only shows the browsers: to stop at a step, or where a scenario fails, pause it.
## Pause a scenario
[Section titled “Pause a scenario”](#pause-a-scenario)
`--pause-at` pauses a run before a step, the line of a feature file; in your editor, a breakpoint on the step’s line and *Debug* do:
```sh
axx run features/shop-portal.feature --pause-at features/shop-portal.feature:24
```
The browser shows, and Playwright’s Inspector opens next to it, with the feature file at the step:
* **Resume**, or **step over** the browser’s actions one at a time.
* **Pick** an element on the page: the Inspector names it as steps do, `the "Weight (grams)" field`, `the "Get a quote" button`. Type a name in its locator box to see what it finds on the page.
* **Record**: use the page, and the Inspector writes the steps you take, ready to copy into the scenario:
```gherkin
When the "Weight (grams)" field is filled with "1200"
And "Germany" is chosen in the "Destination country" field
And the "Get a quote" button is clicked
Then the "testid=quote" element shows "6.90 EUR"
```
Where people see no name for an element, no step can find it: the recording says so, and a label, a name or a test id fixes it.
* The browser is yours meanwhile: click in it, open DevTools.
A run that pauses has no timeouts, however long you look. With `packs.web-core.pauseOnFailure: true` (`axx run --set packs.web-core.pauseOnFailure=true` for one run), a failed scenario pauses the same way, at the step that failed: look at the page as the failure left it. The run says which scenario waits for you. Your editor’s *Debug* does both: it pauses at your breakpoints and where a scenario fails. The Inspector is a Chromium window, whatever the engine: the pack downloads Playwright’s Chromium for it when a run in another engine pauses.
## What a failed scenario keeps
[Section titled “What a failed scenario keeps”](#what-a-failed-scenario-keeps)
* The failed step attaches a screenshot of the page; the [HTML report](/next/guides/reports/) shows it with the step.
* The failure report says which page each web app was on, and the dialog it shows (the `context` of `axx run --json`), and lists the pages’ script errors.
* A Playwright **trace** of each web app, in `.axx/web/traces/` and attached to the report: the scenario step by step, each step’s browser actions under it with a snapshot of the page before and after them, the console and the network. The trace keeps the feature file: its Source tab shows the step. Your editor opens it in Playwright’s trace viewer, where elements have the names steps give them; `npx playwright show-trace ` and [trace.playwright.dev](https://trace.playwright.dev) open it too, with Playwright’s names.
* A **video** of each browser tab, when `videos` says so, in `.axx/web/videos/`, and in the report, where it plays.
* The files the browser **downloaded** that its steps checked, in `.axx/web/downloads/`.
Secrets stay out of all of them but the screenshot and the videos, which show the page as it is ([Keep secrets out of reports](/next/guides/web-sign-in/#keep-secrets-out-of-reports)).
## Settings
[Section titled “Settings”](#settings)
What scenarios keep, and how browsers run, is up to `packs.web-core` in `axx.yaml`:
| Setting | |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `traces` | which scenarios keep a trace: `failed` (the default), `always` or `never` |
| `videos` | which scenarios keep a video of each browser tab: `never` (the default), `failed` or `always` |
| `watch` | `true` shows the browsers in windows on your screen instead of running them unseen |
| `slowdown` | a wait after every action, like `300ms` |
| `pauseOnFailure` | pause a failed scenario where it failed, in Playwright’s Inspector |
| `scriptErrors` | `report` (the default) lists the pages’ script errors with a failed scenario; `fail` fails any scenario whose pages had them |
| `testIdAttribute` | the attribute `testid=` selectors look at (`data-testid` by default) |
# Check accessibility
> Audit pages against the WCAG rules with axe-core, and check what assistive technology reads on them, in the scenarios that use them.
The `web-a11y` pack checks that pages can be used whatever the abilities of the people who use them: that a screen reader can name every field, that text stands out from its background, that images say what they show. It builds on the `web-core` pack, which comes with it: `axx pack add web-a11y` adds both.
## Audit a page
[Section titled “Audit a page”](#audit-a-page)
`the page has no accessibility violations` audits the page with [axe-core](https://github.com/dequelabs/axe-core), the accessibility engine of Deque that Lighthouse runs too, against the WCAG 2.1 AA rules:
```gherkin
Scenario: Shops can get a quote whatever their abilities
When the "/quote" page is opened
Then the page has no accessibility violations
```
A violation fails the step, which says which rule, how serious it is, and which elements break it, with a link to the rule:
```text
The page has 2 accessibility violations (wcag21aa):
label (critical): Form elements must have labels; 1 element: #tracking
https://dequeuniversity.com/rules/axe/4.13/label?application=axx
color-contrast (serious): Elements must meet minimum color contrast ratio thresholds; 1 element: #note
https://dequeuniversity.com/rules/axe/4.13/color-contrast?application=axx
```
The audit looks in every frame of the page, and the report has the violations axe-core found attached, in full. `the "css=form" element has no accessibility violations` audits one part of the page, by its name or a selector.
Audit the page in the state the scenario is about: after the form showed its errors, with the menu open. The pack downloads axe-core 4.13.0 the first time, as the web-core pack downloads its browsers.
## Check what a screen reader reads
[Section titled “Check what a screen reader reads”](#check-what-a-screen-reader-reads)
`the page's accessible structure is:` checks what assistive technology reads on the page, in Playwright’s [ARIA snapshot](https://playwright.dev/docs/aria-snapshots) format: a role and a name a line, nested for what is inside:
```gherkin
Scenario: The registration form says what each field is
When the "/parcels/new" page is opened
Then the "css=form" element's accessible structure is:
"""
- paragraph:
- textbox "Shop account"
- paragraph:
- textbox "Parcel reference"
- group "Service":
- radio "Standard" [checked]
- radio "Express"
- group "Delivery":
- paragraph:
- checkbox "Leave with a neighbour" [checked]
- button "Register the parcel" [disabled]
"""
```
Only what is listed is checked, in its order, as it is nested on the page: here each field is in a paragraph, and the options and the checkbox in their groups. The rest may be anything. A name between slashes is a regular expression, `- paragraph: "/Price: \\d+\\.\\d+ EUR/"` (in quotes, because of the `: `inside). The check waits for the page to match, 10 seconds or `within {duration}`; when it doesn’t, the step says what the page reads instead. The Inspector of a [paused scenario](/next/guides/watch-web-browsers/#pause-a-scenario) records the structure of what you pick, ready to copy.
## Settings
[Section titled “Settings”](#settings)
| Setting | |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `standard` | the WCAG level pages are audited against: `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` (the default) or `wcag22aa`; a level includes those below it |
| `ignore` | rules the audits leave out, by their axe-core id, like `color-contrast` |
axe-core is [MPL-2.0](https://github.com/dequelabs/axe-core/blob/develop/LICENSE), by Deque Systems: Axx downloads it for you, and does not ship it.
# Check what pages show
> Check a web app's pages - the text they show, their elements and their state, tables and counts, the page and its title, downloaded files, script errors and the requests the page sent.
Every check waits for what it expects: 10 seconds, or the time `within {duration}` gives ([Waiting](/next/guides/test-web-apps/#waiting)). When it does not come, the check says what the page shows instead.
## Text
[Section titled “Text”](#text)
`the page shows "Price: 6.90 EUR"` looks for the text anywhere on the page, in every frame, as part of what the page shows; case matters. `the page does not show "Calculating"` waits for it to go. When the text is not there, the failure shows the lines of the page most like it:
```text
The page does not show "Price: 99.00 EUR"
expected: "Price: 99.00 EUR"
actual: "Price: 6.90 EUR · delivered in 2 days"
```
## Elements
[Section titled “Elements”](#elements)
Elements are named as people see them, or by a selector ([Find things the way people do](/next/guides/test-web-apps/#find-things-the-way-people-do)):
```gherkin
Scenario: A parcel on its way can no longer be cancelled
Given a seeds/portal-track.yaml db seed
And the "/parcels/PX-WEB-5112" page is opened
When the "Actions" button is clicked
Then the "Download the label" menu item is shown
And the "Cancel the parcel" menu item is not shown
And the "Actions" button has the aria-expanded attribute "true"
And the "Track this parcel" link has the href attribute "/portal/track/PX-WEB-5112"
```
* `the "…" button is shown` and `… is not shown`, for any kind of element: a `button`, `field`, `checkbox`, `option`, `link`, `tab`, `menu item` or `element`.
* `the "testid=quote" element shows "6.90 EUR"`: the text of one element.
* `the "Actions" button has the aria-expanded attribute "true"`: one of its attributes.
* `the "Weight (grams)" field has the focus`: where the keyboard types.
* `the "Weight (grams)" field has the value "1200"`: what a field holds, or for a drop-down, the text of the option chosen.
## State
[Section titled “State”](#state)
* `the "Leave with a neighbour" checkbox is ticked` and `… is not ticked`.
* `the "Standard" option is selected` and `… is not selected`, for radio buttons.
* `the "Register the parcel" button is disabled` and `… is enabled`; the same for fields: `the "Shop account" field is disabled`.
## Counts and tables
[Section titled “Counts and tables”](#counts-and-tables)
```gherkin
Scenario: A shop sees its parcels
Given a seeds/portal-busy.yaml db seed
When the "/parcels?shop=harbour-hardware" page is opened
Then the page shows 20 "testid=parcel-row" elements
And the page shows a table row where:
| Parcel | PX-WEB-5205 |
| Status | IN_TRANSIT |
And the page shows 3 table rows where:
| Status | IN_TRANSIT |
```
* `the page shows 3 "css=.parcel-card" elements` counts what the page shows, by name or selector.
* `the page shows a table row where:` finds a row by the table’s column headers, and `the page shows 3 table rows where:` counts them. A table’s headers are its ``, or a first row of header cells.
## The page
[Section titled “The page”](#the-page)
* `the "/parcels/PX-WEB-5001" page is shown` is where the browser is: the path below the app’s `url`, with the query only when the step gives one, or a whole URL.
* `the page title is "Get a quote · Parcels"` is the title the browser tab shows.
## Downloaded files
[Section titled “Downloaded files”](#downloaded-files)
```gherkin
When the "Export as CSV" link is clicked
Then the "parcels-birch-and-bloom.csv" file is downloaded
And the downloaded "parcels-birch-and-bloom.csv" file contains "PX-WEB-5101,Lena Hoffmann,EXPRESS,IN_TRANSIT"
And the downloaded "parcels-birch-and-bloom.csv" file has a row where:
| Parcel | PX-WEB-5101 |
| Status | IN_TRANSIT |
```
`the "…" file is downloaded` waits for the browser to download a file with that name, and `the downloaded "…" file is identical to the labels/PX-WEB-5111.zpl file` compares it with a file of the project. `the downloaded "…" file contains "…"` checks part of its text, as its type has it: the text of a PDF’s pages, the paragraphs and tables of a Word document (`.docx`), the cells of an Excel workbook (`.xlsx`), or the text itself. `the downloaded "…" file has a row where:` finds a row of a CSV or TSV file, or of an Excel workbook’s first sheet, by the columns its first row names.
## Script errors
[Section titled “Script errors”](#script-errors)
`the page has no script errors` checks that the web app’s pages had no errors in their scripts since the scenario opened them: none thrown and not caught, and none logged to the console. A failed scenario lists its pages’ script errors either way. With `packs.web-core.scriptErrors: fail` in `axx.yaml`, any script error fails its scenario, without the step.
The browser logs to the console what it fails to load too, like the site’s icon: a browser asks for `/favicon.ico` unless the page names its icon (``), and a site without one has `Failed to load resource: the server responded with a status of 404 (Not Found)` in every page’s console. Give the app its icon.
## Requests the page sent
[Section titled “Requests the page sent”](#requests-the-page-sent)
`the browser sent a POST request to "/quote"` checks that the page sent a request with that method to that address: a path below the web app’s `url` (the query counts only when the step gives one), or a whole URL. What a service did with a request is best checked on the service (its database, its events, its mocks); this step is for what only the browser knows about, such as a call to a third party.
# Measure JavaScript coverage
> Find out how much of the web apps' JavaScript the scenarios run, as lcov and Istanbul reports for editors, CI and coverage services.
The `web-coverage` pack collects the coverage of the scripts the web apps’ pages load while the scenarios run, and writes it when the run ends. It has no steps: add it (`axx pack add web-coverage`, which brings the `web-core` pack it builds on), and every scenario’s pages count.
```text
the web apps' JavaScript coverage: lines 90.32%, statements 90.32%, functions 100%, branches 89.47%, in .axx/web/coverage/lcov.info
```
## What counts
[Section titled “What counts”](#what-counts)
The scripts the pages load from their web app’s origin, named by their URL path, like `portal/js/tracking.js`. A script with a source map counts as its original files instead: the TypeScript, not the bundle. Scripts from other origins (a chat widget, analytics), inline scripts and event handlers in the HTML do not count.
## Name the project’s files
[Section titled “Name the project’s files”](#name-the-projects-files)
`sources` says where the scripts are in the project, by the start of their URL path, so that the reports name the files editors and coverage services know:
axx.yaml
```yaml
packs:
web-coverage:
sources:
/portal/js/: ../app/web/js # the scripts the portal serves under /portal/js/
/portal/src/: ../app/web/src # the original files of their source maps
```
With `sources`, only the scripts under those paths count, named by the folder and the rest of the path, relative to the project: `../app/web/js/tracking.js`. When several paths match, the longest wins. A source map that lacks its original files’ content finds them there too.
## The reports
[Section titled “The reports”](#the-reports)
When the run ends, the pack writes to `.axx/web/coverage` (`folder`):
| File | For |
| ----------------------- | ------------------------------------------------------------------------------------------- |
| `lcov.info` | editors’ coverage gutters, `genhtml`, and coverage services (Codecov, Coveralls, SonarQube) |
| `coverage-final.json` | Istanbul’s own format: `npx nyc report --reporter=html` makes an HTML report of it |
| `coverage-summary.json` | the totals, for CI thresholds and badges |
The run prints the totals at the end.
lcov.info, the start
```text
SF:../app/web/js/tracking.js
FN:3,$
FN:5,day
FNDA:16,$
FNDA:6,day
DA:11,6
DA:12,0
```
A line that ran 0 times is one no scenario reached: in the parcels example, the tracking page’s message for an estimate it cannot fetch.
## How it counts
[Section titled “How it counts”](#how-it-counts)
The pack uses Chromium’s own coverage: the lines, functions and branches that ran, and how many times. It takes it after each step, and before a page leaves its document for another, and adds up every page of every scenario. The reports are those Node’s tools (c8) write from the same coverage, byte for byte.
It works in Chromium, Chrome and Edge. A web app in Firefox or WebKit runs without coverage, and the run says so, once for each web app. Not counted: web workers, frames from other origins, and what runs while a tab a page opened is still loading. Pages run a little slower while the pack collects.
## Settings
[Section titled “Settings”](#settings)
| Setting | |
| --------- | -------------------------------------------------------------------- |
| `folder` | where the reports go, in the project (default `.axx/web/coverage`) |
| `sources` | where the scripts are in the project, by the start of their URL path |
# Audit pages with Lighthouse
> Check a page's Lighthouse scores for performance, accessibility, best practices and SEO, and how fast it loads by the Core Web Vitals.
The `web-lighthouse` pack audits a web app’s pages with [Lighthouse](https://developer.chrome.com/docs/lighthouse), Google’s tool for the quality of web pages, in the browser the scenario already uses. It builds on the `web-core` pack, which comes with it: `axx pack add web-lighthouse` adds both.
```gherkin
Scenario: The quote page is quick and sound on a phone
When the "/quote" page is opened
Then the "/quote" page scores at least:
| performance | 90 |
| accessibility | 100 |
| best practices | 100 |
| seo | 100 |
And the "/quote" page loads within:
| largest contentful paint | 2.5s |
| total blocking time | 200ms |
| cumulative layout shift | 0.1 |
```
The page is a path below the web app’s `url`, or a whole URL. A page of the web app must be open first: the audit runs beside it.
## Scores
[Section titled “Scores”](#scores)
`the {string} page scores at least:` checks the page’s Lighthouse scores, from 0 to 100: `performance`, `accessibility`, `best practices` and `seo`, a row each, with the lowest score each may have. Lighthouse audits the categories listed only. A score below its minimum fails the step, which lists the audits that cost the page the most:
```text
The "/parcels/new" page scores below what it should:
performance: 72, at least 90
Total Blocking Time: 1,150 ms
Cumulative Layout Shift: 0.135
Use efficient cache lifetimes: Est savings of 97 KiB
Improve image delivery: Est savings of 80 KiB
Layout shift culprits
and 2 more in Lighthouse's report
(accessibility: 100, best practices: 100, seo: 100)
```
## Loading
[Section titled “Loading”](#loading)
`the {string} page loads within:` checks how fast the page loads, a metric a row: the Core Web Vitals `largest contentful paint` and `cumulative layout shift`, and `first contentful paint`, `total blocking time` and `speed index`. A limit is a duration (`2.5s`, `200ms`), except for the layout shift, which has no unit (`0.1`). A metric over its limit fails the step, with what would save the most on it. Google’s limits for a good page are 2.5 s for the largest contentful paint, 200 ms of blocking time and a shift of 0.1.
## How it audits
[Section titled “How it audits”](#how-it-audits)
* **Beside the scenario.** Lighthouse loads the page afresh in a tab of its own, with the web app’s cookies and storage: a signed-in page is audited signed in. The scenario’s steps go on in their tab.
* **As a first visit.** Lighthouse clears the browser’s cache and the site’s service workers first, for the scenario’s tabs too.
* **On a phone, or a desktop.** It measures the page as a mid-range phone on a slow 4G connection loads it (the `device` setting: `desktop` for Lighthouse’s desktop preset), simulating the device and the connection rather than slowing the browser down. An audit takes about 5 seconds, and audits take turns in a run, so that scenarios running beside one another do not slow each other’s pages.
* **With a report.** Each audit attaches Lighthouse’s HTML report, which the pack also keeps in `.axx/web/lighthouse`.
Scores and timings vary a little from run to run, and from machine to machine: leave room in the limits. The blocking time is time the browser spends on the CPU, which other scenarios’ browsers share: on a busy machine, such as a CI runner, run the scenarios that time pages alone, after the others, by tagging them and listing the tag in `run.exclusive` ([Run in parallel](/next/guides/parallel-runs/)). Lighthouse audits pages in Chromium, Chrome and Edge only; for a web app in Firefox or WebKit the step fails.
## Settings
[Section titled “Settings”](#settings)
| Setting | |
| -------- | ------------------------------------------------------------------------------------------------------------ |
| `device` | `mobile` (the default): a mid-range phone on a slow 4G connection; or `desktop`: Lighthouse’s desktop preset |
Lighthouse is [Apache-2.0](https://github.com/GoogleChrome/lighthouse/blob/main/LICENSE), by Google: Axx downloads Lighthouse 13.5.0 the first time (about 18 MB, from npm’s registry, or the mirror `npm_config_registry` names) and runs it with the web-core pack’s Node.js. It does not ship it.
# Control what pages fetch
> Make a page's requests fail, answer with a status or a file, or take their time, slow the browser's connection, answer from a recording, and check websocket messages.
Pages fetch from the browser: an estimate, a price, a map, live updates over a websocket. The `web-network` pack sees and changes those requests, to check what a page does when an answer is late, wrong or missing. It builds on the `web-core` pack, which comes with it: `axx pack add web-network` adds both.
These are the pages’ own requests. The services your app calls from its server are mocked on the server’s side instead ([Mock dependencies](/next/guides/mock-dependencies/)).
## Name a request
[Section titled “Name a request”](#name-a-request)
A step names requests by their address:
* a path below the web app’s `url`: with the `url` `http://localhost:8400/portal`, `"/pickups"` is `http://localhost:8400/portal/pickups`;
* or a whole URL, for another site: `"https://maps.example.com/tiles/**"`.
`*` stands for any part of a path (`"/track/*/estimate"`), and `**` for any parts. The query doesn’t count, unless the address has one: `"/pickups"` names `/pickups?day=Friday` too, and `"/pickups?day=Friday"` only that one.
What a step sets holds for every web app of the scenario, from their next request on. Set it before the page opens, so that the page’s first requests get it too. When two steps name the same request, the later one answers it.
A step sees the requests a page starts, not where a redirect takes them: when an answer redirects, the browser follows it within the same request, and a step that names the address it leads to does not apply. So a sign-in or a payment that your server redirects to a provider, like `https://checkout.stripe.com/...`, reaches the real provider, whatever a step says about that address. Keep such a flow local on the server’s side: point the app at a mock of the provider that hands out an address of your app ([Mock dependencies](/next/guides/mock-dependencies/)), and check that the browser gets there.
## Requests that fail or answer
[Section titled “Requests that fail or answer”](#requests-that-fail-or-answer)
```gherkin
Scenario: A shop is told when a pickup cannot be planned for want of a connection
Given a seeds/portal-pickup-offline.yaml db seed
And the page's requests to "/pickups" fail
And the "/parcels?shop=fern-and-flax" page is opened
When the "Pickup PX-WEB-5321" element is dragged onto the "Tomorrow" element
Then the page shows "The pickup could not be planned: check your connection and try again"
```
* `the page's requests to "/pickups" fail`: the request fails as when the service is down or out of reach. The page’s `fetch` throws.
* `the page's requests to "/pickups" answer with status 503`: the request gets that status and nothing else.
* `the page's requests to "/track/*/estimate" answer with the network/estimate-tuesday.json file`: the request gets a file of the project, with the type of its extension. This is for an answer the page only shows, such as one that depends on the day.
* `the page's requests to "/track/*/estimate" take 5s`: the request goes out that much later, as to a busy service. The page shows what it shows while it waits.
```gherkin
Scenario: The tracking page says it is still working out when the parcel arrives
Given a seeds/portal-tracking-waiting.yaml db seed
And the page's requests to "/track/*/estimate" take 5s
When the "/track/PX-WEB-5602" page is opened
Then the page shows "Working out when your parcel arrives"
```
To cut the pages off from everything, use `the browser is offline` ([The connection](/next/guides/web-pages/#the-connection)).
## A slow connection
[Section titled “A slow connection”](#a-slow-connection)
`the browser's connection is slow` makes the browser’s connection that of a phone with a poor signal: 400 ms more for every request, and 500 kbit/s each way. It works in Chromium, Chrome and Edge only: with another engine, the step fails, or the one that opens the page.
```gherkin
Scenario: The tracking page works on a phone with a poor signal
Given a seeds/portal-tracking-slow.yaml db seed
And the browser's connection is slow
When the "/track/PX-WEB-5604" page is opened
Then the page shows "Arrives on Monday, September 28, between 09:00 and 18:00"
```
The checks wait for the page as usual, 10 seconds or `within {duration}`.
## Answer from a recording
[Section titled “Answer from a recording”](#answer-from-a-recording)
With `record: true`, each scenario’s pages record what they fetch, in a HAR file for each web app in `.axx/web/recordings`:
```sh
axx run --set packs.web-network.record=true features/portal-tracking.feature:52
```
Copy a recording into the project and keep the requests you want answered. `the page's requests are answered from the network/tracking-delivered.har recording` then answers the requests the recording has, as they were recorded. The requests it lacks go out, and so do websockets.
```gherkin
Scenario: A delivered parcel's tracking page says when it was delivered
# Recorded from the service, with the parcel's delivery scan in the tracking store.
Given a seeds/portal-tracking-delivered.yaml db seed
And the page's requests are answered from the network/tracking-delivered.har recording
When the "/track/PX-WEB-5605" page is opened
Then the page shows "Delivered on Thursday, September 24, at 10:12"
```
A request matches a recorded one by its URL and method, and its body for a `POST`. So record with the web app’s `url` the scenarios use.
## Websocket messages
[Section titled “Websocket messages”](#websocket-messages)
The pack listens to the websockets of the scenario’s pages from the start:
* `the page sent a websocket message containing "PX-WEB-5607"` checks what a page sent;
* `the page received a websocket message containing "OUT_FOR_DELIVERY"` checks what it received.
Both wait for the message, 10 seconds or `within {duration}`. When it doesn’t come, the step lists the messages that did.
```gherkin
Scenario: The tracking page shows a depot scan as it happens
Given a seeds/portal-tracking-live.yaml db seed
And the "/track/PX-WEB-5606" page is opened
And a depot-scans kafka event
And the depot-scans kafka event key is PX-WEB-5606
And the depot-scans kafka event payload is a kafka/scan-out-for-delivery.json resource
And the depot-scans kafka event payload properties are:
| $.scanId | SC-5606-1 |
| $.parcelRef | PX-WEB-5606 |
When the depot-scans kafka event is published using schema schemas/depot-scan.avsc
Then the page received a websocket message containing "OUT_FOR_DELIVERY"
And the page shows "Out for delivery from Leipzig"
```
## Settings
[Section titled “Settings”](#settings)
| Setting | |
| -------- | --------------------------------------------------------------------------------------------------------------- |
| `record` | record what each scenario’s pages fetch, a HAR file for each web app in `.axx/web/recordings` (default `false`) |
In Chromium, a page that a step answers (from a file or a recording) is not one the browser got from the network. So the browser would treat it as a page of the internet, and keep it from reaching the machine’s own addresses, where the app’s websockets and services are. The pack lets such pages reach them.
# Work with pages
> Do on a web app's pages what people do - fill in forms, click, tap, drag and scroll, press keys, answer dialogs, follow new browser tabs, download files - and set the browser's clock, connection and device.
The steps below act on the current page of the scenario’s web app ([Test web apps](/next/guides/test-web-apps/) sets it up). Each one finds its element by the name people see, or by a selector ([Find things the way people do](/next/guides/test-web-apps/#find-things-the-way-people-do)), waits for it to be ready, and does what a person does.
## Forms
[Section titled “Forms”](#forms)
```gherkin
Scenario: A shop sets its pickup days, and the logo on its labels
When the "/settings?shop=willow-and-wax" page is opened
And the "Pickup address" field is filled with "Weserstr. 40, 12045 Berlin"
And the following options are chosen in the "Pickup days" field:
| Monday |
| Thursday |
And the "Email me when a parcel is delivered" checkbox is checked
And the logos/willow-and-wax.png file is uploaded in the "Upload your logo" field
And the "Save the settings" button is clicked
Then the page shows "Settings saved"
```
* `the "…" field is filled with "…"` replaces what a field holds. Date fields take dates as `2026-10-01`.
* `"Germany" is chosen in the "Destination country" field` picks one option of a drop-down; `the following options are chosen in the "…" field:` picks several, one a row, in a drop-down that takes more than one.
* `the "Express" option is chosen` picks a radio button; `the "…" checkbox is checked` and `… is unchecked` tick and clear a checkbox, and leave it as it is when it already is.
* `the invoices/INV-2041.pdf file is uploaded in the "Customs invoice" field` picks a file of the project, as the file dialog would: for a file field with that label, or for a button with that name that opens the file dialog.
## Clicks, taps and the pointer
[Section titled “Clicks, taps and the pointer”](#clicks-taps-and-the-pointer)
* `the "Get a quote" button is clicked`, `the "Your parcels" link is clicked`, `the "History" tab is clicked` (a tab in a row of tabs on the page), `the "Cancel the parcel" menu item is clicked` (in an open menu), `the "PX-4101" element is clicked` (anything else).
* `the "Ella Brandt" element is double-clicked` and `the "Nora Fischer" element is right-clicked`, which opens the page’s own menu when it has one.
* `the "Menu" button is tapped`, on a touch screen: the web app’s `device` must have one, like `iPhone 15`.
* `the pointer is moved over "About Express"` moves the pointer over a button, link, tab or menu item with that name, or else over that text, so that what opens on hover opens: a menu, a tip.
* `the "Pickup PX-WEB-5302" element is dragged onto the "Friday" element` drags, as with a mouse.
```gherkin
Scenario: A shop tracks a parcel from its row's menu
Given a seeds/portal-row-menu.yaml db seed
And the "/parcels?shop=thistle-and-thread" page is opened
When the "Nora Fischer" element is right-clicked
And the "Track this parcel" menu item is clicked
Then the "/track/PX-WEB-5511" page is shown
```
## Keys
[Section titled “Keys”](#keys)
Keys go to a field, or to whatever has the focus: `the Enter key is pressed in the "Find a parcel" field`, `the Escape key is pressed`. Key names are Playwright’s: `Enter`, `Escape`, `Tab`, `ArrowDown`, `Backspace`, a letter, and combinations like `Control+A`.
## Scrolling
[Section titled “Scrolling”](#scrolling)
Actions scroll to their element on their own. A page that shows more as it scrolls needs more: `the page is scrolled to the bottom` scrolls as far as the page goes, and `the "Pickups" element is scrolled into view` scrolls until it shows (in the page, or in the list it is in). `the page is scrolled to the top` scrolls back.
## Dialogs
[Section titled “Dialogs”](#dialogs)
A dialog a page opens (a message, a confirmation such as “Cancel parcel PX-WEB-5121?”, or a question) waits for an answer, as it does for people, and so does the page: until the dialog is answered, the other steps fail and say that a dialog is open.
```gherkin
Scenario: The portal asks before it cancels a parcel
Given a seeds/portal-ask.yaml db seed
And the "/parcels/PX-WEB-5124" page is opened
And the "Actions" button is clicked
When the "Cancel the parcel" menu item is clicked
Then the dialog shows "Cancel parcel PX-WEB-5124?"
Scenario: A shop cancels a parcel before it is picked up
Given a seeds/portal-cancel.yaml db seed
And the "/parcels/PX-WEB-5121" page is opened
And the "Actions" button is clicked
And the "Cancel the parcel" menu item is clicked
When the dialog is accepted
Then the page shows "Parcel PX-WEB-5121 cancelled"
```
`the dialog is dismissed` answers Cancel, and `the dialog is answered with "The recipient moved"` types the answer to a question and accepts it.
## Browser tabs, going back, reloading
[Section titled “Browser tabs, going back, reloading”](#browser-tabs-going-back-reloading)
A link that opens a new browser tab, such as a tracking page for the recipient, makes it the current tab, as a browser shows it; the steps then act on it. `the browser tab is closed` closes it, and the tab it was opened from is the current tab again.
```gherkin
Scenario: A shop shares a parcel's tracking page with its recipient
Given a seeds/portal-share.yaml db seed
And the "/parcels/PX-WEB-5117" page is opened
When the "Track this parcel" link is clicked
Then the "/track/PX-WEB-5117" page is shown
And the page shows "Your parcel from maple-crafts is in transit."
Scenario: The tracking page opens in a browser tab of its own
Given a seeds/portal-share-closed.yaml db seed
And the "/parcels/PX-WEB-5118" page is opened
And the "Track this parcel" link is clicked
When the browser tab is closed
Then the "/parcels/PX-WEB-5118" page is shown
```
`the browser's back button is clicked` goes back a page, and `the page is reloaded` reloads it.
## Frames
[Section titled “Frames”](#frames)
Steps look inside frames: an embedded map, a pickup point chooser, a payment form from another address. Nothing to do: what people see on the page, the steps find, whichever frame it is in.
## Downloads
[Section titled “Downloads”](#downloads)
A file the page downloads, by a link or a button, is checked by the name the browser gives it:
```gherkin
When the "Download the label" menu item is clicked
Then the "PX-WEB-5111-label.zpl" file is downloaded
And the downloaded "PX-WEB-5111-label.zpl" file is identical to the labels/PX-WEB-5111.zpl file
```
`the downloaded "parcels.csv" file contains "PX-WEB-5101,Lena Hoffmann"` checks part of it ([Check what pages show](/next/guides/web-checks/#downloaded-files)). A failed scenario keeps the downloads its steps checked in `.axx/web/downloads/`. WebKit opens some files in the page instead of downloading them, text files among them, unless their link has the `download` attribute, as a download link should.
## The browser’s clock
[Section titled “The browser’s clock”](#the-browsers-clock)
Pages that show the time, count down, or end a session after a while need a clock the scenario sets:
```gherkin
Scenario: The portal ends a session after 30 minutes without activity
Given the browser's clock is set to "2026-09-25T09:00:00+02:00"
And the "/quote" page is opened
When the browser's clock is moved forward by 31m
Then the page shows "Your session ended after 30 minutes without activity"
```
`the browser's clock is set to "…"` sets the time the pages see, a time like `2026-09-25T14:00:00+02:00` (or `2026-09-25 14:00`, in UTC). Time goes on from there, so the pages’ timers run. `the browser's clock is moved forward by 31m` jumps ahead, as if the computer slept: the timers due in between fire once, and what expires expires. Set the clock before opening the pages whose timers it moves; it holds for every web app of the scenario.
## The connection
[Section titled “The connection”](#the-connection)
`the browser is offline` cuts the web apps’ pages off from everything, and `the browser is online` brings them back: for pages that say they are offline, keep a draft, or sync later.
```gherkin
Scenario: The portal says when it cannot save anything
Given the "/quote" page is opened
When the browser is offline
Then the page shows "You are offline: nothing is saved until you are back online"
Scenario: The portal says when it can save again
Given the "/quote" page is opened
And the browser is offline
When the browser is online
Then the page does not show "You are offline"
```
## Devices, languages and more
[Section titled “Devices, languages and more”](#devices-languages-and-more)
How the browser presents itself is up to the web app’s properties ([Register the web app](/next/guides/test-web-apps/#register-the-web-app)): a `device` (a phone, a tablet), a `viewport`, a `locale` and a `timezone`, a `color scheme` (dark mode), `reduced motion`, the `media` (a page as it prints), a `location`, `permissions` and a `user agent`. A web app that is the same address with other properties is another browser for the same app: a phone next to a desktop, a shop in Berlin next to one in New York.
```gherkin
Scenario: A shop in Germany sees times in its own time zone
Given the berlin web app with the following properties:
| url | http://localhost:8400/portal |
| locale | de-DE |
| timezone | Europe/Berlin |
And a seeds/portal-times.yaml db seed
When the "/parcels/PX-WEB-5131" page of the berlin web app is opened
Then the page shows "Registered: 25.09.2026, 14:03"
```
Pages learn the browser’s `location` on secure addresses only (`https://`, or `localhost`), as browsers allow.
# Compare screenshots
> Check that pages and elements look as designed, pixel by pixel as Playwright compares them, with screenshots kept per platform, each compared on its own.
The `web-screenshots` pack checks that pages look as designed: the page, or one element of it, compared with a screenshot taken before, pixel by pixel, as Playwright does. Small differences of color and anti-aliased edges don’t count. It builds on the `web-core` pack, which comes with it: `axx pack add web-screenshots` adds both.
```gherkin
Scenario: The quote page looks as designed
Given the desktop web app with the following properties:
| url | http://localhost:8400/portal |
| viewport | 1280x800 |
And the browser's clock is set to "2026-09-25T09:00:00Z"
When the "/quote" page of the desktop web app is opened
Then the page looks like the "quote" screenshot
```
A viewport keeps the size of the page the same wherever it runs, and the browser’s clock keeps the time it shows.
## Each platform compares its own
[Section titled “Each platform compares its own”](#each-platform-compares-its-own)
Browsers draw the same page differently on each operating system, fonts first, so a screenshot is compared only on the platform it was taken on. Each engine and platform has its own: `screenshots/quote.chromium-linux.png`, `quote.chromium-darwin.png`. Keep the screenshots of every platform you test on, your Mac and your CI’s Linux say, and each of them compares, locally as in CI. `platforms` names them:
axx.yaml
```yaml
packs:
web-screenshots:
platforms: [linux, darwin]
```
On a platform that is not in the list, the steps pass without comparing and say so; the rest of the scenario runs as usual. Keep only your CI’s platform (`platforms: [linux]`) to compare in CI alone. Without `platforms`, every platform compares, and one without its screenshots takes them.
## Taking and updating screenshots
[Section titled “Taking and updating screenshots”](#taking-and-updating-screenshots)
* The first time, there is no screenshot: the step takes it and fails. Look at it, commit it, and the next runs compare with it.
* When the page looks different, the step attaches the screenshot, the page and their difference to the report (red: what differs; yellow: anti-aliasing), and keeps the page in `.axx/web/screenshots/`. When the change is right, copy that over the screenshot, or delete the screenshot and run again.
* `packs.web-screenshots.update: true` takes every screenshot again, as the pages look now, for the platform it runs on: `axx run --set packs.web-screenshots.update=true` on your Mac, and on Linux in the CI job that compares them, keeping them as an artifact. Take a platform’s screenshots where they are compared: another Linux draws text with other fonts and smoothing, and Playwright’s image (`mcr.microsoft.com/playwright`) does not draw like GitHub’s Ubuntu runners.
* The check waits for the page to settle, such as a price that arrives, before it fails.
## One element
[Section titled “One element”](#one-element)
`the "testid=quote" element looks like the "express quote" screenshot` compares one element, by its name or a selector: a card, a chart, a label, without the rest of the page around it.
## What changes from run to run
[Section titled “What changes from run to run”](#what-changes-from-run-to-run)
A page that shows the time, a generated number or today’s date looks different every run. Paint it over, in both the screenshot and the page, one element a row, by its text, its label or a selector:
```gherkin
Scenario: A parcel's page looks as designed, whatever the time it was registered
Given the desktop web app with the following properties:
| url | http://localhost:8400/portal |
| viewport | 1280x800 |
And a seeds/portal-look.yaml db seed
When the "/parcels/PX-WEB-5181" page of the desktop web app is opened
Then the page looks like the "parcel" screenshot, apart from:
| css=time |
```
What is painted over in either screenshot is left out of the comparison, so an element may change size from run to run, like a time whose digits differ.
Better still, give pages fixed data: a seeded parcel, and the browser’s clock set to a time ([The browser’s clock](/next/guides/web-pages/#the-browsers-clock)).
## Settings
[Section titled “Settings”](#settings)
| Setting | |
| ----------- | --------------------------------------------------------------------------------------------------------------------------- |
| `platforms` | the platforms whose screenshots you keep, and compare on: `linux`, `darwin`, `windows` (every platform by default) |
| `folder` | the folder of the screenshots, in the project; unset, each feature keeps its own in a `screenshots` folder next to its file |
| `tolerance` | the share of a screenshot’s pixels that may differ, like `0.01` (one pixel in a hundred); keep it at 0 when you can |
| `update` | take every screenshot again instead of comparing |
# Sign in to web apps
> Sign in the way people do, through every page of your identity provider, or start scenarios with a session - cookies, a token, storage or HTTP credentials - and keep passwords and tokens out of reports and traces.
Every scenario has a browser context of its own, a fresh browser profile, so each one signs in: nothing a scenario signs in to is shared with another, which is what lets them run side by side ([How the web-core pack works](/next/explanations/web-browsers/#isolation)). There are two ways.
## Sign in as people do
[Section titled “Sign in as people do”](#sign-in-as-people-do)
Signing in is pages, fields and buttons like any others, however many pages it takes: the browser follows your app to its identity provider and back, as it does for people. A `Background` does it for all the scenarios of a feature:
```gherkin
Background:
Given the parcels web app with the following properties:
| url | http://localhost:8400/portal |
And the "/account" page is opened
And the "Sign in" link is clicked
And the "Log in with Email" button is clicked
And the "Email Address" field is filled with "orders@fjord-outdoor.example"
And the "Password" field is filled with "${env:FJORD_OUTDOOR_PASSWORD}"
And the "Login" button is clicked
And the "Grant Access" button is clicked
And the page shows "Signed in as Fjord Outdoor"
```
This is the sign-in your shops go through, so it is checked in every scenario, and against a provider in your Compose file it takes about a second.
## Start with a session
[Section titled “Start with a session”](#start-with-a-session)
When a scenario is about what a signed-in shop does rather than about signing in, start its browser signed in, with what your app keeps its session in. Each is a property of the web app, and each goes to the app’s own address only:
| Property | |
| ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `cookie.` | a cookie, such as a session cookie: `cookie.session` |
| `header.` | a header on every request to the app, such as a token: `header.Authorization` with `Bearer ${env:SHOP_TOKEN}` |
| `local storage.` | an item of the page’s local storage, where single-page apps often keep their tokens |
| `session storage.` | an item of each tab’s session storage, where some sign-in libraries keep theirs |
| `username`, `password` | HTTP authentication, for apps (or test environments) behind a login prompt |
```gherkin
Scenario: A returning shop sees its parcels
Given the shop web app with the following properties:
| url | http://localhost:8400/portal |
| local storage.token | ${env:SHOP_TOKEN} |
When the "/parcels" page of the shop web app is opened
Then the page shows "Signed in as Fjord Outdoor"
```
Where the token comes from is up to your test environment: a long-lived test token in the environment, one your identity provider issues to a test client, or a session your service creates for a seeded shop. Scenarios still share nothing: every scenario’s browser starts with its own copy.
A step of your own can wrap either way in your own words, like `Given the shop is signed in as fjord-outdoor` ([Write custom steps](/next/guides/write-custom-steps/)).
## Keep secrets out of reports
[Section titled “Keep secrets out of reports”](#keep-secrets-out-of-reports)
Keep passwords and tokens in the environment, as `${env:…}`. Whatever a step takes from the environment is a secret: failure messages, the failure report and saved traces show it as `********`, down to the form data the browser sends and the headers of its requests. Screenshots and videos show the page as it is: a password field shows only dots, so type secrets into password fields.
## An identity provider next to your apps
[Section titled “An identity provider next to your apps”](#an-identity-provider-next-to-your-apps)
For your own tests, run [Dex](https://dexidp.io), a small OpenID Connect provider, in the Compose file with shops of your choosing. Your app signs in with it as it does with your real provider:
compose.yaml
```yaml
services:
dex:
image: ghcr.io/dexidp/dex:v2.45.1
command: ['dex', 'serve', '/etc/dex/config.yaml']
volumes: ['./dex.yaml:/etc/dex/config.yaml:ro']
ports: ['5556:5556']
parcels:
build: ../app
ports: ['8400:8400']
extra_hosts: ['dex.localhost:host-gateway'] # Dex, where the browsers find it
```
dex.yaml
```yaml
issuer: http://dex.localhost:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
staticClients:
- id: parcels-portal
name: Parcels portal
secret: portal-client-secret
redirectURIs: ['http://localhost:8400/portal/callback']
enablePasswordDB: true
staticPasswords:
- email: orders@fjord-outdoor.example
# htpasswd -nbBC 10 "" "$FJORD_OUTDOOR_PASSWORD" | tr -d ':\n'
hash: ''
username: Fjord Outdoor
userID: fjord-outdoor
connectors:
- type: mockCallback
id: mock
name: Example
```
* The `issuer` is one address for both the browser and your app. The browsers, on your machine, take any `*.localhost` name for your machine, where Dex’s port is published; `extra_hosts` sends your app’s container there too. Give your app that issuer, the client’s `id` and `secret`, and its callback. An app that runs on your machine needs no `extra_hosts`.
* Dex asks which way to sign in (`Log in with Email` or `Log in with Example`), then for the email address and password, then to grant access. `oauth2: {skipApprovalScreen: true}` leaves out the last page.
* `Log in with Example` signs in a fixed test user, Kilgore Trout, without a password: enough for scenarios about what a signed-in shop sees rather than about signing in.
* Dex keeps no session of its own: after signing out of your app, signing in asks for the password again.
# Test webhooks and tokens
> Sign the webhooks a scenario sends your service, check the signatures of the webhooks your service sends, and authorize requests with JSON Web Tokens and OAuth 2.0 client credentials tokens.
Services call each other back: a courier calls your service as it delivers parcels, and your service calls each shop’s system in turn. Each side signs what it sends, so the other can trust it. And an API that shops’ systems call wants a token. The `rest` and `mock` packs cover both directions:
* **Sign the requests a scenario sends,** as the other side would.
* **Check the signatures of the webhooks your service sends,** on the WireMock that stands in for the receiver.
* **Authorize requests with a token:** one axx signs, or one it gets from a token endpoint.
```gherkin
Scenario: A shop hears of its parcel's delivery in a signed webhook
Given a seeds/callback-delivered.yaml db seed
And a POST request to /api/courier/callbacks
And a request payload using an application/json content example named 'Delivered'
And the request payload property reference is 'PX-WHK-9103'
And the request is signed in the X-Courier-Signature header with the following properties:
| key | ${env:COURIER_CALLBACK_KEY} |
| value | sha256={signature} |
When the request is executed
Then the response status code is 204
And the mocked POST request to path /webhooks/linden-and-lace named delivered was received by shops
And the mocked request named delivered on shops is signed as a standard webhook with the key '${env:SHOP_WEBHOOK_KEY}'
```
## Sign what a scenario sends
[Section titled “Sign what a scenario sends”](#sign-what-a-scenario-sends)
A webhook is signed with an HMAC of what it sends, in a header. A table says how:
```gherkin
Given the request is signed in the X-Courier-Signature header with the following properties:
| key | ${env:COURIER_CALLBACK_KEY} |
| value | sha256={signature} |
```
| Row | What it takes | Default |
| ------------------ | -------------------------------------------------------------------------------------------------------------- | ------------- |
| `key` | the secret the signature is made with (required) | |
| `algorithm` | `hmac-sha256`, `hmac-sha1` or `hmac-sha512` | `hmac-sha256` |
| `signs` | what is signed: a template of `{body}`, `{timestamp}` (Unix seconds), `{method}` and `{path}` (with the query) | `{body}` |
| `encoding` | `hex` or `base64` | `hex` |
| `value` | the header’s value: a template of `{signature}` and `{timestamp}` | `{signature}` |
| `timestamp header` | a header that also carries `{timestamp}` | |
The table covers the usual forms:
| Service | `signs` | `value` | Other rows |
| ------------------------------ | ----------------------- | ------------------------------ | ------------------------------------------------- |
| GitHub (`X-Hub-Signature-256`) | `{body}` | `sha256={signature}` | |
| Stripe (`Stripe-Signature`) | `{timestamp}.{body}` | `t={timestamp},v1={signature}` | |
| Slack (`X-Slack-Signature`) | `v0:{timestamp}:{body}` | `v0={signature}` | `timestamp header` is `X-Slack-Request-Timestamp` |
[Standard Webhooks](https://www.standardwebhooks.com) have a step of their own. It sets the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, with a key that is `whsec_` and the key in base64:
```gherkin
Given the request is signed as a standard webhook with the key '${env:SHOP_WEBHOOK_KEY}'
```
The signature is made when the request is executed, over the body exactly as it is sent. Like the other request steps, both take `for 2nd ordered request` and `for request on `.
## Check what your service signed
[Section titled “Check what your service signed”](#check-what-your-service-signed)
The receiving end of your service’s webhooks is a WireMock ([Mock dependencies](/next/guides/mock-dependencies/)). Name the request first, then check its signature with the same table, or as a Standard Webhook:
```gherkin
Then the mocked POST request to path /webhooks/linden-and-lace named delivered was received by shops
And the payload properties for mocked request named delivered on shops are:
| reference | PX-WHK-9103 |
And the mocked request named delivered on shops is signed as a standard webhook with the key '${env:SHOP_WEBHOOK_KEY}'
```
Axx signs what WireMock received, byte for byte, with its method and its path, and compares the signatures. `{timestamp}` comes from the header’s value or from the timestamp header. The check fails unless the name matches a request, and every request it matches must be signed. So narrow the name to the scenario’s own data, as the payload properties above do, since other scenarios’ webhooks reach the same WireMock.
## Authorize requests with a token
[Section titled “Authorize requests with a token”](#authorize-requests-with-a-token)
Register a token under a name, then authorize requests with it:
```gherkin
Given the shop token with the following properties:
| key | ${env:SHOP_TOKEN_KEY} |
| claim.shop | hawthorn-home |
And a GET request to /api/shops/hawthorn-home/parcels
And the request is authorized with the shop token
```
A token is one of two kinds:
* **A JSON Web Token axx signs,** with rows:
* `algorithm`: `HS256` (the default), `HS384`, `HS512`, `RS256` or `ES256`.
* `key`: the shared secret, or for RS and ES, the PEM private key as a file of the project.
* `key id`: the `kid` in the token’s header.
* `claim.`: a claim, as a JSON value (`true`, `42`, `["parcels:read"]`) or text.
* `expires in`: how long it lasts, `5m` unless it says otherwise.
It is signed each time it is used, with `iat` and `exp` from then.
* **An OAuth 2.0 client credentials token axx gets from a token endpoint,** with rows `token url`, `client id`, `client secret`, and optionally `scope` and `audience`. It is got once for the run, and again once it expires:
```gherkin
Given the shop-system token with the following properties:
| token url | http://localhost:8400/oauth/token |
| client id | wisteria-way |
| client secret | ${env:WISTERIA_CLIENT_SECRET} |
```
The request step sends `Authorization: Bearer `. In other packs’ property rows, `${token:}` stands for the token, as in a WebSocket’s header:
```gherkin
Given the tracking websocket with the following properties:
| url | ws://localhost:8400/portal/track/PX-LIV-5701/live |
| header.Authorization | Bearer ${token:shop} |
```
It works in the rows of what a scenario registers for itself (the `websocket`, `sse`, `grpc`, `jsonrpc` and `graphql` packs), and in the `cli` pack’s command, arguments, input and environment. It doesn’t work in the rows of message brokers, whose connections are opened for the whole run before any scenario registers a token.
## Keep secrets secret
[Section titled “Keep secrets secret”](#keep-secrets-secret)
Signing keys, client secrets and tokens are masked in logs and failures, whether they come from `${env:..}` or are written in the table. Keep real keys out of feature files anyway: `${env:..}` reads them from the environment, which CI fills from its secrets.
See the [rest](/next/references/packs/rest/) and [mock](/next/references/packs/mock/) packs’ references for every step.
# Write custom steps
> Add your own Gherkin steps as a Go pack that shares the context of Axx's packs.
Custom steps sit next to the steps of Axx’s packs and are used the same way in feature files. They live in a **pack** of your own: Go code that builds on Axx’s core, like Axx’s packs, and works on the same context their steps use.
## Create a pack
[Section titled “Create a pack”](#create-a-pack)
Create a pack and add it to the project in one go:
```console
$ axx pack new ./steps
created the steps pack in ./steps and added it to axx-packs.yaml
its example step: Then the steps pack is loaded
```
A pack is a Go package that exports `Pack()`. Its steps receive the scenario, and from it every pack’s context: the services registered in the scenario and their state. A custom step sees exactly what the steps of Axx’s packs set up, and their steps see what it adds.
steps/pack.go
```go
package steps
import (
"github.com/nimbusxr/axx/core"
"github.com/nimbusxr/axx/packs/sql"
)
func Pack() core.Pack { return pack{} }
type pack struct{}
func (pack) Manifest() core.Manifest {
return core.Manifest{
Name: "steps",
Steps: []core.StepDef{{
ID: "steps.rows",
Keyword: "Then",
Expr: "the {word} table on {dbService} holds {int} row(s)",
Doc: "Counts the rows of a table on a registered database.",
Examples: []string{"Then the parcels.manifest_lines table on parcels-db holds 3 rows"},
Run: func(sc *core.Scenario, a core.Args) error {
db := a.Value(1).(*sql.Service) // {dbService} resolves to the registered service
sel, err := db.Query(sc.Context(), a.String(0), "SELECT * FROM "+a.String(0))
if err != nil {
return err
}
if got := len(sel.Rows); got != a.Int(2) {
return core.Fail("rows in "+a.String(0), a.Int(2), got)
}
return nil
},
}},
}
}
```
The contexts:
| Pack | Context | Holds |
| ------- | ------------------- | ------------------------------------------------------------------------------------------ |
| `rest` | `rest.Context(sc)` | REST services, their requests in order, and each request’s response (`Exchange()`) |
| `sql` | `sql.Context(sc)` | databases with their connection pool (`DB`), selections, triggers; `Query`, `AddSelection` |
| `mongo` | `mongo.Context(sc)` | MongoDB databases (`DB()`) and their selections |
| `mock` | `mock.Context(sc)` | mocked (WireMock) services |
| `kafka` | `kafka.Context(sc)` | Kafka services, their topic clients (`Topic(name)`) and the events drafted for each topic |
Every context has `Service(name...)` (no name: the default, the first registered), `Services()` and `AddService(...)`, and `sql.Connect`, `mongo.Connect` and `mock.NewService` create services the way the packs’ own steps do. Report failed expectations with `core.Fail(message, expected, actual)` so reports show both values, and keep state of your own in a `core.NewStateKey`: it is per scenario, like the packs’ contexts.
A step parameter that names a file is a `{filepath}`, and a step whose `key | value` table has file properties names them in `TableTypes`: `map[string]string{"schema": "filepath"}` for a file the step reads, or `"url"` for a URL whose `file://` form names a file that may appear only during the run. Read the file with `sc.Suite().ResolvePath(a.String(0))`, which looks in the `resources` directories and next to `axx.yaml`: that is where editors look to link the value, complete its path and flag a missing file ([Set up your editor](/next/guides/set-up-your-editor/)).
The first `axx` command that needs the project’s steps prepares Axx with the pack, like any other pack in `axx-packs.yaml` ([Choose packs](/next/guides/use-packs/)).
## Use the step
[Section titled “Use the step”](#use-the-step)
Once the pack is listed in `axx-packs.yaml`, its steps are used like any other step: `axx steps search` finds them, `axx validate` checks feature lines against them, and `axx skills install` adds them to the agent step index. `axx docs export --out ` writes the reference of the project’s packs, yours included: a page per pack, with its steps, their documentation and examples.
## Change a REST request
[Section titled “Change a REST request”](#change-a-rest-request)
A custom step can change a request the rest pack’s steps built, until it is executed: sign it, or give it a token the service expects. A request’s headers are the ones it is sent with:
* `Header()` returns them, and `Set`, `Add` and `Del` change them, as with Go’s `http.Request.Header`.
* `SetHeader(name, value)` does what the request header steps do: `Content-Type` and `Accept` replace an earlier value, and other headers get one more.
* When the request is sent, it gets `Accept: */*`, its payload’s `Content-Type` and a `User-Agent` if it has none. `Exchange().RequestHeader` has the headers it was sent with, and `Exchange().Header` the response’s.
This step signs a request’s payload for a service that checks an `X-Signature` header, like a carrier’s scan webhook:
steps/pack.go
```go
{
ID: "steps.sign",
Keyword: "Given",
Expr: "the request is signed with the key {string}",
Doc: "Signs the default REST service's request: an X-Signature header with the HMAC-SHA256 of its payload.",
Examples: []string{"Given the request is signed with the key '${env:CARRIER_KEY}'"},
Run: func(sc *core.Scenario, a core.Args) error {
svc, err := rest.Context(sc).Service()
if err != nil {
return err
}
req, err := svc.Request(0)
if err != nil {
return err
}
if req.Payload == nil {
return errors.New("the request has no payload to sign")
}
mac := hmac.New(sha256.New, []byte(a.String(0)))
mac.Write([]byte(*req.Payload))
req.Header().Set("X-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
return nil
},
},
```
```gherkin
Scenario: A carrier's signed scan is accepted
Given a POST request to /api/scans
And a request payload using an application/json content example
And the request is signed with the key '${env:CARRIER_KEY}'
When the request is executed
Then the response status code is 202
```
The step signs the payload as the steps before it left it, so it comes after the payload steps.
## Give agents a tool
[Section titled “Give agents a tool”](#give-agents-a-tool)
A pack can also give coding agents tools of their own, in `axx mcp`: `Tools` in its manifest. A tool looks at the scenario an agent keeps open with `steps_try`, the one its steps ran in, and returns data (and images) for the agent. The web-core pack’s `web_page` shows the page the steps led to ([Set up agents](/next/guides/set-up-agents/#mcp-server)).
```go
Tools: []core.Tool{{
Name: "steps_manifest_lines",
Description: "The manifest lines the session's steps imported, as the importer stored them.",
ReadOnly: true,
Input: json.RawMessage(`{"type": "object", "properties": {}}`),
Run: func(call *core.ToolCall) (*core.ToolResult, error) {
db, err := sql.Context(call.Scenario).Service()
if err != nil {
return nil, err
}
sel, err := db.Query(call.Context, "manifest_lines", "SELECT * FROM parcels.manifest_lines")
if err != nil {
return nil, err
}
return &core.ToolResult{Data: map[string]any{"rows": sel.Rows}}, nil
},
}},
```
Name a tool after its pack, and describe what it gives and when to use it: agents choose tools by their descriptions. The input is checked against its JSON Schema before `Run`.
## Check what the pack needs
[Section titled “Check what the pack needs”](#check-what-the-pack-needs)
`axx doctor` runs a pack’s `Checks` in the projects that use it: that what its steps need is there, before a scenario finds out. The mobile packs check the Android SDK and Xcode this way.
```go
Checks: []core.Check{{
Name: "carrier signing key",
Run: func(ctx context.Context) core.CheckResult {
if os.Getenv("CARRIER_KEY") == "" {
return core.CheckResult{Status: core.CheckWarn, Detail: "CARRIER_KEY is not set", Hint: "set it to the key carriers sign their scans with"}
}
return core.CheckResult{Status: core.CheckOK, Detail: "CARRIER_KEY is set"}
},
}},
```
A check says what it found (`Detail`), and, when something is wrong, what puts it right (`Hint`). `CheckWarn` is for what only some scenarios need; `CheckFail` for what none can run without.
# CLI reference
> Every Axx command and flag, generated from the help text of the installed binary.
Axx runs executable acceptance criteria, in Gherkin people can read, as black-box tests against running services and apps. Its packs cover REST and OpenAPI, WireMock, SQL, MongoDB, Kafka, logs, files, web apps in real browsers, and AWS, Google Cloud and Azure services: a project lists those it uses in axx-packs.yaml, beside packs of its own steps.
Writing tests: axx steps every step of the project’s packs, one line each axx steps show \ one step’s documentation and examples axx up start the services once and keep them running axx run –compact run the features; also reports what validate and lint find (axx validate checks the features without running them)
Docs: (every page as Markdown: add .md to its path; llms.txt lists them)
Axx (github.com/nimbusxr/axx, “axxeptance”) is an open-source acceptance testing framework, the behavioral verification layer for agentic software factories: it verifies running software against executable acceptance criteria, black-box.
| Command | Purpose |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| [`axx run`](/next/references/cli/axx-run/) | Start the services, run feature files, and report |
| [`axx validate`](/next/references/cli/axx-validate/) | Check feature files without running them: syntax, undefined and ambiguous steps |
| [`axx steps`](/next/references/cli/axx-steps/) | List, search and inspect the available Gherkin steps |
| [`axx explain`](/next/references/cli/axx-explain/) | Show which step definition a line matches, or what an error code means |
| [`axx up`](/next/references/cli/axx-up/) | Start the services from axx.yaml and keep them running between `axx run`s |
| [`axx down`](/next/references/cli/axx-down/) | Stop services started by `axx up` (and any left behind by an interrupted run) |
| [`axx doctor`](/next/references/cli/axx-doctor/) | Check that this project and machine are ready to run Axx |
| [`axx init`](/next/references/cli/axx-init/) | Set up Axx in this project: axx.yaml, its packs, a smoke feature, CI, AGENTS.md and agents |
| [`axx schema`](/next/references/cli/axx-schema/) | Print the JSON Schema for axx.yaml or a fixture spec file |
| [`axx docs`](/next/references/cli/axx-docs/) | Generate reference documentation from the installed version |
| [`axx mcp`](/next/references/cli/axx-mcp/) | Serve Axx to coding agents over the Model Context Protocol (stdio) |
| [`axx skills`](/next/references/cli/axx-skills/) | Install agent skills that teach coding agents to use Axx |
| [`axx version`](/next/references/cli/axx-version/) | Print the axx version |
| [`axx completion`](/next/references/cli/axx-completion/) | Generate the autocompletion script for the specified shell |
| [`axx config`](/next/references/cli/axx-config/) | The project’s configuration: config show |
| [`axx env`](/next/references/cli/axx-env/) | The services Axx started (running, left over, not cleaned up); env up and env down start and stop them |
| [`axx fixtures`](/next/references/cli/axx-fixtures/) | Generate, check and adopt schema-governed fixture files (the fixture factory) |
| [`axx host`](/next/references/cli/axx-host/) | Start the services for runs that cannot start them, like an agent’s in a sandbox without Docker |
| [`axx ide`](/next/references/cli/axx-ide/) | Generate IDE run/debug configurations from axx.yaml |
| [`axx lint`](/next/references/cli/axx-lint/) | Check test-data isolation: values that must stay unique across seeds, fixtures and features |
| [`axx lsp`](/next/references/cli/axx-lsp/) | Serve feature-file editing to editors over the Language Server Protocol (stdio) |
| [`axx models`](/next/references/cli/axx-models/) | Stub embedded models: models whose answers are their mappings |
| [`axx pack`](/next/references/cli/axx-pack/) | Manage the packs this project uses (axx-packs.yaml) |
```text
Usage:
axx [flags]
axx [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
config The project's configuration: config show
docs Generate reference documentation from the installed version
doctor Check that this project and machine are ready to run axx
down Stop services started by `axx up` (and any left behind by an interrupted run)
env The services axx started (running, left over, not cleaned up); env up and env down start and stop them
explain Show which step definition a line matches, or what an error code means
fixtures Generate, check and adopt schema-governed fixture files (the fixture factory)
help Help about any command
host Start the services for runs that cannot start them, like an agent's in a sandbox without Docker
ide Generate IDE run/debug configurations from axx.yaml
init Set up axx in this project: axx.yaml, its packs, a smoke feature, CI, AGENTS.md and agents
lint Check test-data isolation: values that must stay unique across seeds, fixtures and features
lsp Serve feature-file editing to editors over the Language Server Protocol (stdio)
mcp Serve axx to coding agents over the Model Context Protocol (stdio)
models Stub embedded models: models whose answers are their mappings
pack Manage the packs this project uses (axx-packs.yaml)
run Start the services, run feature files, and report
schema Print the JSON Schema for axx.yaml or a fixture spec file
skills Install agent skills that teach coding agents to use axx
steps List, search and inspect the available Gherkin steps
up Start the services from axx.yaml and keep them running between `axx run`s
validate Check feature files without running them: syntax, undefined and ambiguous steps
version Print the axx version
Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
-h, --help help for axx
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
--version axx version print the axx version (as axx version)
Use "axx [command] --help" for more information about a command.
```
Every command accepts `--json` and prints one [JSON envelope](/next/references/json-output/). Exit codes are listed in [exit codes](/next/references/error-codes/#exit-codes).
# axx completion
> Generate the autocompletion script for the specified shell
Generate the autocompletion script for Axx for the specified shell. See each sub-command’s help for details on how to use the generated script.
```text
Usage:
axx completion [command]
Available Commands:
bash Generate the autocompletion script for bash
fish Generate the autocompletion script for fish
powershell Generate the autocompletion script for powershell
zsh Generate the autocompletion script for zsh
Flags:
-h, --help help for completion
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
Use "axx completion [command] --help" for more information about a command.
```
## axx completion bash
[Section titled “axx completion bash”](#axx-completion-bash)
Generate the autocompletion script for the bash shell.
This script depends on the ‘bash-completion’ package. If it is not installed already, you can install it via your OS’s package manager.
To load completions in your current shell session:
```plaintext
source <(axx completion bash)
```
To load completions for every new session, execute once:
#### Linux:
[Section titled “Linux:”](#linux)
```plaintext
axx completion bash > /etc/bash_completion.d/axx
```
#### macOS:
[Section titled “macOS:”](#macos)
```plaintext
axx completion bash > $(brew --prefix)/etc/bash_completion.d/axx
```
You will need to start a new shell for this setup to take effect.
```text
Usage:
axx completion bash
Flags:
-h, --help help for bash
--no-descriptions disable completion descriptions
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx completion fish
[Section titled “axx completion fish”](#axx-completion-fish)
Generate the autocompletion script for the fish shell.
To load completions in your current shell session:
```plaintext
axx completion fish | source
```
To load completions for every new session, execute once:
```plaintext
axx completion fish > ~/.config/fish/completions/axx.fish
```
You will need to start a new shell for this setup to take effect.
```text
Usage:
axx completion fish [flags]
Flags:
-h, --help help for fish
--no-descriptions disable completion descriptions
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx completion powershell
[Section titled “axx completion powershell”](#axx-completion-powershell)
Generate the autocompletion script for powershell.
To load completions in your current shell session:
```plaintext
axx completion powershell | Out-String | Invoke-Expression
```
To load completions for every new session, add the output of the above command to your powershell profile.
```text
Usage:
axx completion powershell [flags]
Flags:
-h, --help help for powershell
--no-descriptions disable completion descriptions
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx completion zsh
[Section titled “axx completion zsh”](#axx-completion-zsh)
Generate the autocompletion script for the zsh shell.
If shell completion is not already enabled in your environment you will need to enable it. You can execute the following once:
```plaintext
echo "autoload -U compinit; compinit" >> ~/.zshrc
```
To load completions in your current shell session:
```plaintext
source <(axx completion zsh)
```
To load completions for every new session, execute once:
#### Linux:
[Section titled “Linux:”](#linux-1)
```plaintext
axx completion zsh > "${fpath[1]}/_axx"
```
#### macOS:
[Section titled “macOS:”](#macos-1)
```plaintext
axx completion zsh > $(brew --prefix)/share/zsh/site-functions/_axx
```
You will need to start a new shell for this setup to take effect.
```text
Usage:
axx completion zsh [flags]
Flags:
-h, --help help for zsh
--no-descriptions disable completion descriptions
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx config
> The project's configuration: config show
The project’s configuration: config show
```text
Usage:
axx config [command]
Available Commands:
show Show the effective axx.yaml (profiles and -D applied, secrets masked) and the project's packs
Flags:
-h, --help help for config
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
Use "axx config [command] --help" for more information about a command.
```
## axx config show
[Section titled “axx config show”](#axx-config-show)
Show the effective axx.yaml (profiles and -D applied, secrets masked) and the project’s packs
```text
Usage:
axx config show [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for show
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx docs
> Generate reference documentation from the installed version
Generate reference documentation from the installed version
```text
Usage:
axx docs [command]
Available Commands:
export Write generated reference pages (packs, parameter types, error codes, schema) to a directory
Flags:
-h, --help help for docs
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
Use "axx docs [command] --help" for more information about a command.
```
## axx docs export
[Section titled “axx docs export”](#axx-docs-export)
Write the reference documentation generated from this binary: one page per pack (its settings, steps, parameter types and agent tools) and an overview of them, parameter types, a grep-friendly step index, the error and exit codes, and the JSON Schemas of axx.yaml and the fixture spec files. In a project, it covers the project’s packs, those axx-packs.yaml lists, custom packs included; elsewhere, the packs compiled into this Axx (the documentation site is built this way).
```text
Usage:
axx docs export [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
--frontmatter emit YAML front matter (for the docs site) (default true)
-h, --help help for export
-o, --out string output directory
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx doctor
> Check that this project and machine are ready to run Axx
Check the Axx installation, axx.yaml, feature files, step definitions and the commands your services need. Agents: run this first in a new repo.
Exit code 0 when nothing failed (warnings allowed), 4 otherwise.
```text
Usage:
axx doctor [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for doctor
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx down
> Stop services started by `axx up` (and any left behind by an interrupted run)
Stop services started by `axx up` (and any left behind by an interrupted run)
```text
Usage:
axx down [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for down
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx env
> The services Axx started (running, left over, not cleaned up); env up and env down start and stop them
Report the services Axx started from axx.yaml, as the Axx MCP server’s env tool does: running (axx up, or a run, still holds them), left over (they run, but what started them does not: axx down stops them) or not cleaned up (their cleanup failed or never ran: axx down runs it). env status is axx env; env up and env down are axx up and axx down.
```text
Usage:
axx env [flags]
axx env [command]
Available Commands:
down Stop services started by `axx up` (and any left behind by an interrupted run)
status The services axx started (the same as `axx env`)
up Start the services from axx.yaml and keep them running between `axx run`s
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for env
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
Use "axx env [command] --help" for more information about a command.
```
## axx env down
[Section titled “axx env down”](#axx-env-down)
Stop services started by `axx up` (and any left behind by an interrupted run)
```text
Usage:
axx env down [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for down
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx env status
[Section titled “axx env status”](#axx-env-status)
The services Axx started (the same as `axx env`)
```text
Usage:
axx env status [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for status
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx env up
[Section titled “axx env up”](#axx-env-up)
Start the services (all enabled ones, or the ones named) in the background and wait until they are ready. Later `axx run` invocations reuse them instead of starting and stopping them each time, which is the fastest local loop. Stop them with `axx down`. Service output goes to .axx/logs/services.log.
```text
Usage:
axx env up [services...] [flags]
Flags:
--debug string[="*"] start services with their debug command (all, or a comma-separated list)
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for up
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx explain
> Show which step definition a line matches, or what an error code means
Explain how Axx reads a step line: the matching definition and captured arguments, the candidates if it is ambiguous, or the closest steps if it is undefined. The leading Gherkin keyword is optional.
Given an error code such as AXX-E0102, print what it means and how to fix it.
Exit code 0 means the line matches exactly one step, 3 means it is undefined or ambiguous.
```text
Usage:
axx explain "" | AXX-Exxxx [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for explain
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx fixtures
> Generate, check and adopt schema-governed fixture files (the fixture factory)
The fixture factory expands \*.factory.yaml, \*.fixture.yaml and \*.prototype.yaml sources into fixture files (Avro/JSON/YAML/XML/protobuf payloads, database seed datasets) that are validated against their schemas, and records what it owns in axx-fixtures.manifest.yaml. Only files recorded there are ever touched; a managed file whose content no longer matches its sha256 is a refused hand-edit.
Configure it in the fixtures section of axx.yaml. The spec file formats: axx schema –kind factory|fixture|prototype.
Exit codes: 0 ok, 1 drift or a failed check/generation, 2 invalid configuration or spec files, 4 git unavailable (untrack).
```text
Usage:
axx fixtures [command]
Available Commands:
adopt Turn existing fixture files into factory sources, preserving every value
check Verify committed fixtures match their specs (drift) and conform to their schemas
clean Delete ignored generated outputs from disk (generate rematerializes them)
explain Say where a value of a generated fixture comes from
generate Expand the factory specs into fixture files, the manifest and generated lint rules
untrack Take ignored outputs that are still tracked out of the git index (git rm --cached)
Flags:
-h, --help help for fixtures
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
Use "axx fixtures [command] --help" for more information about a command.
```
## axx fixtures adopt
[Section titled “axx fixtures adopt”](#axx-fixtures-adopt)
Adopt hand-written fixture files: the prototype takes the values every file shares (–prototype common: each field’s most common value), and each file becomes a \*.fixture.yaml carrying only its differences. String fields whose value is distinct in every file are proposed as identity candidates, with how many files they were compared across; declare the ones that identify a fixture with –identity (repeatable). Before anything is written the new spec is expanded and every file must decode equal to its original; otherwise adoption refuses, lists the paths that would change, and writes nothing. –dry-run does all of it and writes nothing either.
axx fixtures adopt –schema schemas/order.avsc –files ‘kafka/orders/*.json’ –factory orders –dry-run axx fixtures adopt –schema schemas/order.avsc –files ‘kafka/orders/*.json’ –factory orders –identity order\_id axx fixtures adopt –into orders –files ‘features/refunds/\*.json’
Paths and globs are relative to fixtures.baseDir.
```text
Usage:
axx fixtures adopt [flags]
Examples:
axx fixtures adopt --family json --schema openapi/api.yaml#/components/schemas/Order \
--files 'mocks/orders/*.json' --factory order-bodies
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
--dry-run verify the adoption and report without writing anything
--factory string name of the new factory (written next to the adopted files)
--family string fixture family of the adopted files (default avro)
--files string glob of the files to adopt, relative to fixtures.baseDir
-h, --help help for adopt
--identity stringArray declare an identity candidate as an identity (repeatable), e.g. --identity order_id
--into string adopt into this existing factory (name or root-relative path)
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--prototype string what the prototype takes: shared (the values every file shares) or common (each field's most common value) (default "shared")
--schema string governing schema, relative to fixtures.baseDir
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx fixtures check
[Section titled “axx fixtures check”](#axx-fixtures-check)
Read-only verification: every managed file must equal what the factories generate, the manifest must list exactly what they produce, and every file a conformance rule matches must pass its family’s schema oracle. Recorded expression functions resolve from the committed pairings lock only.
Ignored outputs (fixtures.output.ignored) a checkout lacks pass when the committed manifest records what the sources produce, so a fresh clone checks clean; run `axx fixtures generate` after check to write them. Check first: generate would bring a stale manifest up to date and hide it.
Exit codes: 0 all checks pass, 1 any failure (drift fails CI), 2 invalid configuration or spec files.
```text
Usage:
axx fixtures check [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for check
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx fixtures clean
[Section titled “axx fixtures clean”](#axx-fixtures-clean)
Delete the ignored outputs (the gitignored derivations of factory sources) from disk. Committed outputs, sources, the manifest, the pairings lock and the managed .gitignore files are never touched; directories the deletion empties are removed.
```text
Usage:
axx fixtures clean [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
--dry-run list what would be deleted without deleting
-h, --help help for clean
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx fixtures explain
[Section titled “axx fixtures explain”](#axx-fixtures-explain)
Say where the value at \ in a generated fixture file comes from, and so which source to edit: the fixture’s own data, a prototype overlay, the factory’s prototype, a defaults: entry or the schema’s default, with the file and the place in it. A value that comes through a $ref, an expression or an identity says so, then where that comes from.
\ is a file `axx fixtures generate` writes (or its fixture’s \*.fixture.yaml), from the working directory or the fixtures base dir. \ is dotted, with indices: recipient.postcode, items\[0].sku; a dataset path starts with the table: orders\[0].status.
Read-only: recorded expression functions resolve from the committed pairings.
Exit codes: 0 explained, 1 the specs cannot be expanded, 2 a file the factory does not generate or a path with no single value.
```text
Usage:
axx fixtures explain [flags]
Examples:
axx fixtures explain requests/order-7731.json recipient.country
axx fixtures explain kafka/scan-delivered.json scanId --json
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for explain
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx fixtures generate
[Section titled “axx fixtures generate”](#axx-fixtures-generate)
Expand every factory spec into canonical fixture bytes and write the files that changed, the managed .gitignore entries for ignored outputs, the pairings lock of recorded expression functions, the generated lint rules and the manifest.
A managed file whose content no longer matches the manifest is a hand-edit: generate refuses to overwrite it (lift the change into the spec, or re-adopt the file). There is no force option that discards edits.
```text
Usage:
axx fixtures generate [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
--dry-run report what would be written without writing anything
-h, --help help for generate
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx fixtures untrack
[Section titled “axx fixtures untrack”](#axx-fixtures-untrack)
The one-time step after outputs become ignored: .gitignore rules only govern untracked files, so previously committed copies must leave the index. Runs git rm –cached for ignored outputs that are still tracked. Working-tree files are never touched and the removal is staged, not committed; re-running is harmless.
```text
Usage:
axx fixtures untrack [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
--dry-run list what would be untracked without touching the index
-h, --help help for untrack
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx host
> Start the services for runs that cannot start them, like an agent's in a sandbox without Docker
Serve the project’s runs that cannot start its services themselves: an agent’s, in a sandbox that keeps it from Docker. Start it in the project, on your own machine, and leave it running. It listens on a loopback port and writes .axx/host.json, which Axx finds there in the sandbox: each `axx run`, `axx up` and MCP session asks the host for its services. The host cleans up, starts them as axx.yaml says, and stops them and runs their cleanup when the run ends, or when its connection does. The scenarios run where Axx runs: the sandbox’s rules apply to them. Runs take turns with the services.
Only a definition you approved starts: axx.yaml’s services, what each compose project they run resolves to, and the files their commands run. Starting the host approves it as it is, unless it changed since you last approved one: then it asks you as it starts. When the definition changes, the host asks you here, with the difference and what in it reaches past a container (privileged containers, this machine’s files, the Docker socket, its network). A run waits a minute for your y or n; the question stays open after, and the next run has your answer. A change you refuse is refused again until the host restarts. Approvals are kept in your configuration directory, outside the project.
With –ide, an editor that started the host asks instead: the host writes its events as lines of JSON and reads the editor’s answers on its standard input.
```text
Usage:
axx host [flags]
Flags:
--addr string the address to listen on (port 0: one the system picks) (default "127.0.0.1:0")
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for host
--ide talk to the editor that started the host: events as JSON lines on stdout, its answers on stdin
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx ide
> Generate IDE run/debug configurations from axx.yaml
Generate run and debug configurations for the services in axx.yaml that have a debug.debugger block. Re-running updates them; files or entries you edited are kept. Entries are named “Debugger: \” (IntelliJ) and “Axx: …” (VS Code).
```text
Usage:
axx ide [command]
Available Commands:
intellij Write .run/*.run.xml: Debugger: listeners, axx: run/debug/validate, and a one-click compound
vscode Merge axx attach configurations into .vscode/launch.json and tasks into tasks.json
Flags:
-h, --help help for ide
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
Use "axx ide [command] --help" for more information about a command.
```
## axx ide intellij
[Section titled “axx ide intellij”](#axx-ide-intellij)
Write .run/\*.run.xml: Debugger: \ listeners, Axx: run/debug/validate, and a one-click compound
```text
Usage:
axx ide intellij [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for intellij
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx ide vscode
[Section titled “axx ide vscode”](#axx-ide-vscode)
Merge Axx attach configurations into .vscode/launch.json and tasks into tasks.json
```text
Usage:
axx ide vscode [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for vscode
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx init
> Set up Axx in this project: axx.yaml, its packs, a smoke feature, CI, AGENTS.md and agents
Create axx.yaml, axx-packs.yaml (with the rest pack, which the smoke feature uses), features/smoke/smoke.feature, a GitHub Actions workflow and a managed section in AGENTS.md. Existing files are left alone (use –force to overwrite); the AGENTS.md section is updated in place. Safe to re-run.
The service in axx.yaml is a guess to replace: with a compose file, it starts that file as a stack of its own (`-p -axx`), whose cleanup removes the stack’s volumes but never those of the stack you develop with; the port (8080) and health check (/health), in axx.yaml and the smoke feature, are placeholders.
It also sets up coding agents, in this repository only: it installs the skills in .agents/skills (marked generated in .gitattributes, so their diffs collapse in pull requests), and connects the Axx MCP server to the agents the repository already uses, in their own files (Claude Code: .claude/, CLAUDE.md or .mcp.json → .mcp.json and .claude/skills; Codex: .codex/ → .codex/config.toml; Cursor: .cursor/ → .cursor/mcp.json; VS Code: .vscode/ → .vscode/mcp.json; Gemini CLI: .gemini/ or GEMINI.md → .gemini/settings.json). Other servers and settings in those files stay. Claude Code reads CLAUDE.md instead of AGENTS.md when this folder or one above it has a CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md: there, init imports AGENTS.md into the project’s CLAUDE.md (@AGENTS.md). It never writes your home directory: for Codex, whose servers usually live in \~/.codex/config.toml, it prints the command that adds Axx there. –no-agents skips all of this.
```text
Usage:
axx init [flags]
Flags:
--dry-run show what would be written without writing
--force overwrite existing axx.yaml, axx-packs.yaml, feature and workflow files
-h, --help help for init
--no-agents do not install the skills or connect agents to the axx MCP server
--no-ci do not create a GitHub Actions workflow
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx lint
> Check test-data isolation: values that must stay unique across seeds, fixtures and features
Run the test-data isolation rules from the lint section of axx.yaml: each rule extracts values (ids, keys, names) from the files it selects, with a regular expression or a JSON path, and reports values that repeat where they must be unique, so scenarios sharing a database can run in parallel. Builtin checks also warn about SQL selection and trigger ordinals that cannot work.
With paths, only findings touching those files or directories are reported (every file is still scanned: a value collides with occurrences anywhere).
Formats (repeatable, NAME or NAME:FILE; FILE is relative to axx.yaml): human grouped by rule, with file:line and the colliding value (default) json the –json envelope junit one test case per rule sarif SARIF 2.1.0 for code scanning (paths relative to the repository) github GitHub Actions ::error/::warning annotations
Exit codes: 0 clean (or only warnings, e.g. rules in warn mode), 2 invalid configuration, 3 violations.
```text
Usage:
axx lint [paths...] [flags]
Examples:
axx lint
axx lint seeds/manifest-kestrel.yaml
axx lint --format github --format sarif:build/axx/lint.sarif
axx lint --mode warn
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-f, --format stringArray output NAME or NAME:FILE (repeatable): human, json, junit, sarif, github
-h, --help help for lint
--mode string override the mode of every rule, the built-in feature checks included: error or warn
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx lsp
> Serve feature-file editing to editors over the Language Server Protocol (stdio)
Start a language server on stdin/stdout for feature files: undefined, ambiguous and misused steps and Gherkin syntax errors as you type, step completion, step documentation on hover, going to a step’s definition, highlighted step parameters, and links to, path completion for and warnings about missing files that steps name.
Editors start it themselves: the Axx plugin for IntelliJ IDEA and the Axx extension for VS Code do, and other editors can run “axx lsp” for \*.feature files. Each Axx project the editor opens gets the steps of its own packs, custom packs included. When a project’s axx.yaml, its axx-packs.yaml or the code of a pack of its own changes, the server loads the project’s steps again, in editors that report changed files (IntelliJ IDEA and VS Code do).
```text
Usage:
axx lsp [flags]
Flags:
-h, --help help for lsp
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx mcp
> Serve Axx to coding agents over the Model Context Protocol (stdio)
Start an MCP server on stdin/stdout exposing tools to search and explain steps, validate features, run scenarios, inspect failures and manage services.
Connect an agent to it with `axx mcp install --agent ` (axx init does it for the agents a repository already uses), or add it yourself, e.g. in .mcp.json: {“mcpServers”: {“Axx”: {“command”: “Axx”, “args”: \[“mcp”]}}}
```text
Usage:
axx mcp [flags]
axx mcp [command]
Available Commands:
install Add the axx MCP server to a coding agent's configuration
Flags:
-h, --help help for mcp
--profile string config profile for all tool calls
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
Use "axx mcp [command] --help" for more information about a command.
```
## axx mcp install
[Section titled “axx mcp install”](#axx-mcp-install)
Add the Axx MCP server to one agent’s configuration, merged into the file the agent reads: other servers and settings stay as they are, and a file that already has Axx is left alone.
agent project scope (the default) user scope claude .mcp.json prints `claude mcp add --scope user ...` codex .codex/config.toml \~/.codex/config.toml ($CODEX\_HOME) cursor .cursor/mcp.json \~/.cursor/mcp.json vscode .vscode/mcp.json prints `code --add-mcp ...` gemini .gemini/settings.json \~/.gemini/settings.json
Claude Code keeps its user servers with its own state, and VS Code in its profile, so for them Axx prints the agent’s own command instead. Codex loads a project’s .codex/config.toml once you trust the project. Only –scope user writes to your home directory.
```text
Usage:
axx mcp install [flags]
Examples:
axx mcp install --agent claude
axx mcp install --agent codex --scope user
Flags:
--agent string the agent: claude, codex, cursor, vscode, gemini
--dry-run show what would be written without writing
-h, --help help for install
--scope string project (this repository's files) or user (your home directory) (default "project")
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx models
> Stub embedded models: models whose answers are their mappings
Stub the models apps and services run themselves. A stub’s directory has its model’s contract (contract.json, which `axx models contract` writes from the real model) and its mappings (mappings/\*.json); `axx models build` writes the stub, a model the app’s test build or test deployment puts where the app gets the real one.
```text
Usage:
axx models [command]
Available Commands:
build Build a stub, a model of its contract that answers its mappings
contract Write a model's contract, what a stub of it keeps to
Flags:
-h, --help help for models
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
Use "axx models [command] --help" for more information about a command.
```
## axx models build
[Section titled “axx models build”](#axx-models-build)
Build the stub in the directory \, from its contract (contract.json) and its mappings (mappings/\*.json), as a model of the contract’s format: the file the app’s test build or test deployment puts where the app gets the real model. Keep it with the app’s sources: it is built the same, bit for bit, on every machine. A mapping that cannot be used stops the build, named.
```text
Usage:
axx models build [stub] [flags]
Flags:
--contract string the contract file (with --mappings, instead of a stub directory)
-h, --help help for build
--mappings string the mappings directory (with --contract)
-o, --out string the stub's file, like app/src/debug/assets/photo-check.tflite (required)
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx models contract
[Section titled “axx models contract”](#axx-models-contract)
Read a model and write its contract: its inputs and outputs, its format’s version and its metadata, what an app relies on from the model. A stub keeps to it, so the app’s runtime loads the stub as it loads the model. An input shaped like an image gets an image layout: check it, and its channels’ order (RGB or BGR) and resizing (center-crop or stretch). A classifier’s contract gives its task and its labels (–task classification –labels labels.txt).
A model the platform gives the app (iOS’s Vision, Android’s ML Kit, a browser’s Prompt API) has no file to read: name its API instead, and the contract is the API’s (axx models contract ios.vision.text). A labeler’s contract takes the labels its mappings answer (–labels labels.txt).
The project keeps the contract in the stub’s directory (contract.json): the stub is built from it, and no test needs the real model. Models: coreml (.mlmodel, .mlpackage), ctranslate2 (.bin), executorch (.pte), foundation-models (.swift), gguf (.gguf), keras (.keras, .h5), litertlm (.litertlm), mlc (.json, a folder of mlc-chat-config.json), mnn (.mnn), mnn-llm (.json), ncnn (.param), onnx (.onnx), openai-whisper (.pt), openvino (.xml), parakeet (.gguf), piper (.onnx), platform (.axxmodel), pytorch-mobile (.ptl), safetensors (.safetensors, a folder of model.safetensors, a folder of model.safetensors.index.json), savedmodel (.pb, a folder of saved\_model.pb), tesseract (.traineddata), tfjs (.json), tflite (.tflite, .lite), torchscript (.pt, .torchscript), whispercpp (.bin). APIs: android.gemini-nano, android.mlkit.image-labeling, android.mlkit.object-detection, android.mlkit.segmentation, android.mlkit.text-recognition, android.speech, ios.foundation-models, ios.private-cloud-compute, ios.speech, ios.vision.animals, ios.vision.classify, ios.vision.humans, ios.vision.person-segmentation, ios.vision.text, macos.foundation-models, macos.private-cloud-compute, macos.speech, macos.vision.animals, macos.vision.classify, macos.vision.humans, macos.vision.person-segmentation, macos.vision.text, web.language-detector, web.language-model, web.proofreader, web.rewriter, web.speech, web.summarizer, web.translator, web.writer, windows.ai.image-description, windows.ai.language-model, windows.ai.object-extractor, windows.ai.text-recognizer, windows.ai.text-rewriter, windows.ai.text-summarizer, windows.ai.text-to-table.
```text
Usage:
axx models contract [flags]
Flags:
-h, --help help for contract
--input stringArray an input's type and shape, which the model does not say (a TorchScript module's) or which the app gives otherwise: 'name=type[dims]', like 'images=float32[1,3,224,224]' (? for a dimension of any size; quoted, as shells read [ and ?)
--labels string a file of a classifier's labels, one a line, in the order of their scores (a text recognizer's: the characters it reads, its blank an empty line)
-o, --out string the contract's file, like models/photo-check/contract.json (default: print it)
--output stringArray an output's type and shape, as --input gives an input's
--task string what the model's outputs mean: classification, detection, embedding, segmentation, speech-to-text, text-generation, text-recognition, text-to-speech
--vocab string a file of a text model's WordPiece vocabulary (BERT's vocab.txt), which the contract carries: its input of tokens' ids is matched by text
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx pack
> Manage the packs this project uses (axx-packs.yaml)
Packs are sets of steps. A project lists the packs it uses in axx-packs.yaml: Axx’s packs by name (rest, sql, kafka, logs, aws-s3, gcp-pubsub, …; see `axx pack list`), and packs of its own, Go packages that build on the public core and the packs’ contexts, by path (./steps) or Go module path (github.com/team/axx-grpc\@v1.2.0). Axx prepares itself with the listed packs on first use and caches the result.
```text
Usage:
axx pack [command]
Available Commands:
add Add packs: axx's by name, others by path or Go module
list List the project's packs and axx's packs it does not use
new Create a pack in and add it to the project
remove Remove packs from this project
update Move module packs to their newest (or requested) versions and rebuild
Flags:
-h, --help help for pack
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
Use "axx pack [command] --help" for more information about a command.
```
## axx pack add
[Section titled “axx pack add”](#axx-pack-add)
Add packs: Axx’s by name, others by path or Go module
```text
Usage:
axx pack add ... [flags]
Examples:
axx pack add rest sql
axx pack add ./steps
axx pack add github.com/team/axx-grpc@v1.2.0
Flags:
-h, --help help for add
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx pack list
[Section titled “axx pack list”](#axx-pack-list)
List the project’s packs and Axx’s packs it does not use
```text
Usage:
axx pack list [flags]
Aliases:
list, ls
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for list
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx pack new
[Section titled “axx pack new”](#axx-pack-new)
Create a pack of your own: a Go package that builds on the public core (github.com/nimbusxr/axx/core) and can use the contexts of Axx’s packs (github.com/nimbusxr/axx/packs/rest, …/sql, …). It is added to axx-packs.yaml; the next command that needs the project’s steps prepares Axx with it.
```text
Usage:
axx pack new [flags]
Flags:
-h, --help help for new
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx pack remove
[Section titled “axx pack remove”](#axx-pack-remove)
Remove packs from this project
```text
Usage:
axx pack remove ... [flags]
Aliases:
remove, rm
Flags:
-h, --help help for remove
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx pack update
[Section titled “axx pack update”](#axx-pack-update)
Clear the pinned versions of module packs (all of them, or the ones named) in axx-packs.lock and rebuild, so each resolves to the version in axx-packs.yaml or, without one, the latest.
```text
Usage:
axx pack update [module]... [flags]
Flags:
-h, --help help for update
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx run
> Start the services, run feature files, and report
Run Gherkin scenarios against your services.
Axx starts the applications declared in axx.yaml (only those needed by the selected scenarios when active.enabled is set), waits until they are ready, runs scenarios in parallel, stops the services and runs their cleanups.
Select scenarios with paths (features/x.feature:14 selects the scenario on line 14, or the scenario containing that step line), –tags and –name. –shard 1/3 runs a third of what they select, for CI’s runners to share a run: every third scenario, in their files’ and lines’ order, from the first; 2/3 and 3/3 run the rest.
Exit codes: 0 passed, 1 failures, 2 usage/config, 3 undefined/ambiguous steps, 4 a service failed to start, 130 interrupted.
```text
Usage:
axx run [paths[:line]...] [flags]
Examples:
axx run
axx run features/register-parcels.feature:17
axx run --tags "@smoke and not @wip" --format junit:build/axx/junit.xml
axx run --shard 2/3 # one of three runners: a third of the scenarios
axx run --attach api # you run the api from your IDE; axx waits for it
axx run --debug=api # start api with its debug command
axx run --debug-steps features/register-parcels.feature:17 # stop at breakpoints in step code
axx run features/shop-portal.feature --pause-at features/shop-portal.feature:24 # pause before that step, where packs can show it
Flags:
--attach strings services you run yourself; axx waits for their readiness instead of starting them
--debug string[="*"] start services with their debug command (all, or a comma-separated list)
--debug-steps string[="2345"] run under Delve so a Go debugger can stop at breakpoints in step code (port, default 2345)
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
--dry-run match steps without executing them or starting services
--fail-fast stop scheduling scenarios after the first failure
-f, --format stringArray reporter NAME or NAME:FILE (repeatable): pretty, progress, compact, junit, messages, cucumber-json, html, agent, teamcity
-h, --help help for run
-n, --name stringArray only scenarios whose name matches this regular expression (repeatable)
--no-start do not start, stop or clean up any service
--order string scenario order: "defined" or "random[:seed]"
--pause-at stringArray pause before the step at this file:line, with no timeouts, for the packs that can show a person what the scenario does (repeatable)
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--rerun-file string write failed scenario locations (file:line) to this file
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
--shard string run shard i of n ("i/n", from 1) of the selected scenarios: every nth, in their files' and lines' order
--slowdown string pause after each action of a watched run, e.g. "500ms" (run.slowdown)
-t, --tags string tag expression, e.g. "@smoke and not @wip"
--watch show what the scenarios do as they do it: browsers and devices in their windows, one scenario at a time unless --workers says otherwise (run.watch)
-w, --workers int parallel scenarios (default: run.workers, or number of CPUs)
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx schema
> Print the JSON Schema for axx.yaml or a fixture spec file
Print a JSON Schema, for editors and agents: axx.yaml by default (config, or Axx), or factory, fixture or prototype for the \*.factory.yaml, \*.fixture.yaml and \*.prototype.yaml files of `axx fixtures`, as an argument or with –kind. Reference them from the files with:
# yaml-language-server: $schema=
[Section titled “yaml-language-server: $schema=https://axx.nimbusxr.us/schemas/v0/axx.schema.json”](#yaml-language-server-schemahttpsaxxnimbusxrusschemasv0axxschemajson)
# yaml-language-server: $schema=
[Section titled “yaml-language-server: $schema=https://axx.nimbusxr.us/schemas/v0/axx-factory.schema.json”](#yaml-language-server-schemahttpsaxxnimbusxrusschemasv0axx-factoryschemajson)
```text
Usage:
axx schema [config|factory|fixture|prototype] [flags]
Flags:
-h, --help help for schema
--kind string which schema: config (axx.yaml), factory, fixture or prototype (default "config")
-o, --out string write to a file instead of stdout
--outline print the keys the file may have, nested, each with one line of description (a fraction of the schema's size)
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx skills
> Install agent skills that teach coding agents to use Axx
Axx ships Agent Skills (SKILL.md + references) for Claude Code, Codex, Cursor, Gemini CLI, Copilot and other agents that read .agents/skills. The step references are generated from this project’s steps.
```text
Usage:
axx skills [command]
Available Commands:
install Install or update the skills (project: .agents/skills + .claude/skills)
list List the skills axx provides
Flags:
-h, --help help for skills
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
Use "axx skills [command] --help" for more information about a command.
```
## axx skills install
[Section titled “axx skills install”](#axx-skills-install)
Write the skills to .agents/skills/ (read by Codex, Cursor, Gemini CLI and Copilot) and, when the project uses Claude Code (a .claude directory, CLAUDE.md or .mcp.json), link them into .claude/skills/ for it: –claude links them anyway, –no-claude never. Re-running updates them; files you edited are kept unless –force.
```text
Usage:
axx skills install [flags]
Flags:
--claude link into .claude/skills even if the project does not use Claude Code
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
--force overwrite skill files you edited
-h, --help help for install
--no-claude do not link into .claude/skills
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--scope string project (this repo) or user (your home directory) (default "project")
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx skills list
[Section titled “axx skills list”](#axx-skills-list)
List the skills Axx provides
```text
Usage:
axx skills list [flags]
Flags:
-h, --help help for list
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx steps
> List, search and inspect the available Gherkin steps
List every step Axx understands (the packs this project loads). Agents: search before writing a feature, and never invent step text.
```text
Usage:
axx steps [flags]
axx steps [command]
Available Commands:
explain Same as `axx explain`: Show which step definition a line matches, or what an error code means
list List every step (the same as `axx steps`)
search Find steps by intent, e.g. `axx steps search "response status"`
show Show one step's documentation, variants and examples
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for steps
--pack string only steps from this pack (e.g. rest, sql, kafka)
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
Use "axx steps [command] --help" for more information about a command.
```
## axx steps explain
[Section titled “axx steps explain”](#axx-steps-explain)
Explain how Axx reads a step line: the matching definition and captured arguments, the candidates if it is ambiguous, or the closest steps if it is undefined. The leading Gherkin keyword is optional.
Given an error code such as AXX-E0102, print what it means and how to fix it.
Exit code 0 means the line matches exactly one step, 3 means it is undefined or ambiguous.
```text
Usage:
axx steps explain "" | AXX-Exxxx [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for explain
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
--pack string only steps from this pack (e.g. rest, sql, kafka)
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx steps list
[Section titled “axx steps list”](#axx-steps-list)
List every step (the same as `axx steps`)
```text
Usage:
axx steps list [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for list
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
--pack string only steps from this pack (e.g. rest, sql, kafka)
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx steps search
[Section titled “axx steps search”](#axx-steps-search)
Find steps by intent, e.g. `axx steps search "response status"`
```text
Usage:
axx steps search [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for search
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
--pack string only steps from this pack (e.g. rest, sql, kafka)
-v, --verbose count increase log verbosity (-v, -vv)
```
## axx steps show
[Section titled “axx steps show”](#axx-steps-show)
Show one step: by its id (rest.response.status), its expression as `axx steps` lists it, or a step line as a feature has it (the leading keyword is optional), matched the way `axx explain` matches it. Several ids show each of their steps.
```text
Usage:
axx steps show [flags]
Aliases:
show, inspect
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for show
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
--pack string only steps from this pack (e.g. rest, sql, kafka)
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx up
> Start the services from axx.yaml and keep them running between `axx run`s
Start the services (all enabled ones, or the ones named) in the background and wait until they are ready. Later `axx run` invocations reuse them instead of starting and stopping them each time, which is the fastest local loop. Stop them with `axx down`. Service output goes to .axx/logs/services.log.
```text
Usage:
axx up [services...] [flags]
Flags:
--debug string[="*"] start services with their debug command (all, or a comma-separated list)
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for up
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx validate
> Check feature files without running them: syntax, undefined and ambiguous steps
Parse feature files and match every step against the available step definitions, without starting applications or executing anything.
Also prints the warnings of axx lint’s feature checks (SQL and REST ordinals that cannot work), and hints at scenarios whose checks prove little (only a success status code, or only that something did not happen); neither changes the exit code.
Exit codes: 0 valid, 2 syntax/config error, 3 undefined or ambiguous steps.
```text
Usage:
axx validate [paths...] [flags]
Flags:
-D, --define stringArray set a property for ${sys:name}, e.g. -D local.host=docker
-h, --help help for validate
--profile string apply profiles. / axx..yaml, several comma-separated in order, like watch,ios (env: AXX_PROFILE)
--set stringArray set a key of axx.yaml for this run, over its files and profiles, e.g. --set run.workers=1 or --set packs..=value
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# axx version
> Print the axx version
Print the axx version
```text
Usage:
axx version [flags]
Flags:
-h, --help help for version
Global Flags:
--compact print only what matters (auto-enabled for coding agents)
-c, --config string path to axx.yaml (default: search upward from the working directory)
--json emit machine-readable JSON (stable envelope)
--no-color disable colored output
-v, --verbose count increase log verbosity (-v, -vv)
```
# Configuration
> The Axx configuration files - axx.yaml (every section, the services and lint keys, how files and profiles merge, interpolation) and axx-packs.yaml.
An acceptance project has up to two configuration files, side by side: `axx.yaml` for the project and `axx-packs.yaml` for the packs it uses. Both are optional.
## axx.yaml
[Section titled “axx.yaml”](#axxyaml)
Without an `axx.yaml`, Axx runs `features/` with defaults. The complete, versioned definition is the JSON Schema:
* online: [`https://axx.nimbusxr.us/schemas/v0/axx.schema.json`](/next/schemas/v0/axx.schema.json)
* offline: `axx schema` (or `axx schema --out axx.schema.json`); `axx schema --outline` lists the keys, a line each
Add this first line to get completion and validation in editors that use the YAML language server (VS Code, IntelliJ, Neovim):
```yaml
# yaml-language-server: $schema=https://axx.nimbusxr.us/schemas/v0/axx.schema.json
```
### A complete example
[Section titled “A complete example”](#a-complete-example)
axx.yaml
```yaml
# yaml-language-server: $schema=https://axx.nimbusxr.us/schemas/v0/axx.schema.json
version: 1
run:
paths: [features] # feature files or directories
tags: "not @wip" # default tag expression
uses: [web-core] # only the scenarios that use one of these packs (a profile per platform)
workers: auto # parallel scenarios: a number or auto (CPUs)
exclusive: ["@isolated"] # these run alone, after the parallel phase
order: defined # or random, random:
timeouts: {step: 60s, scenario: 5m, hook: 2m}
watch: false # show what scenarios do: browsers and devices on screen (axx run --watch)
slowdown: 300ms # in a watched run, pause after each action (axx run --slowdown)
reporters: [pretty, {junit: build/axx/junit.xml}]
resources: ["."] # where seed, payload and schema paths in steps resolve, after the feature file's directory
properties: # ${sys:name}; override with -D name=value
local.host: localhost
openapi:
levels: # suite-wide OpenAPI validation levels
validation.request.security.missing: IGNORE
active:
enabled: false # start only the services the selected scenarios' tags need
onNoTags: fallback
services:
api:
dir: .
command: docker compose up --build
ready:
http:
url: http://${sys:local.host}:8080/health
timeout: 120s
cleanup: docker compose down -v --remove-orphans
lint: {} # axx lint rules
fixtures: {} # axx fixtures settings
profiles: # overlays: --profile ci, several in order (--profile ios,watch), or AXX_PROFILE
ci:
properties: {local.host: docker}
```
### Sections
[Section titled “Sections”](#sections)
| Section | What it configures | Guide |
| ---------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `version` | the schema version of this file; currently `1` | |
| `run` | paths, default tags, workers, exclusive tags, order, timeouts, default reporters | [Run in parallel](/next/guides/parallel-runs/), [Reports](/next/guides/reports/) |
| `resources` | directories that relative file paths in steps resolve against, in order; the directory of `axx.yaml` is searched last | [Configure services](/next/guides/configure-services/#resource-paths) |
| `properties` | values for `${sys:name}` | [Configure services](/next/guides/configure-services/#keep-values-out-of-feature-files) |
| `openapi.levels` | default OpenAPI validation levels, by rule key | [Validate against OpenAPI](/next/guides/validate-openapi/) |
| `active` | service startup by the selected scenarios’ tags and packs | [Manage the services under test](/next/guides/manage-services/#start-only-what-a-run-needs) |
| `services` | the system under test ([keys](#services)) | [Manage the services under test](/next/guides/manage-services/) |
| `packs` | settings for packs, keyed by pack name | each pack’s page under [Packs](/next/references/packs/) |
| `lint` | test-data isolation rules ([keys](#lint)) | [Isolate test data](/next/guides/isolate-test-data/) |
| `fixtures` | fixture factory settings | [Fixture factories](/next/guides/fixture-factories/) |
| `profiles` | named overlays | [below](#finding-and-merging-files) |
### services
[Section titled “services”](#services)
Each key under `services:` names one service: something that runs on its own, which scenarios connect to. A program a scenario runs as its user would, like a command-line tool or an MCP server over stdio, belongs in its step (`command`), not here.
| Key | Meaning |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `command` | How to start the service: a string (split into words, no shell) or an argv list. A command that exits `0` before the service is ready is fine: Axx keeps polling `ready`. |
| `shell` | `true` runs `command` and `cleanup` through the shell, for pipes and `&&`. |
| `dir`, `env` | Working directory (relative to `axx.yaml`) and extra environment. |
| `dependsOn` | Services that must be ready first. Independent services start in parallel. |
| `enabled` | `false` skips the service. Defaults to `true`. |
| `ready` | Checks that must all pass: `http.url` (every URL returns 2xx), `tcp` (`host:port` accepts connections), `exec` (a command exits 0), `log` (a regular expression matches the service’s output). `timeout` defaults to 60s, `interval` to 1s. |
| `stop` | `signal` (`SIGTERM` by default, or `SIGINT`) is sent to the service’s process group; after `grace` (default 10s) it is killed. |
| `cleanup` | Runs after the service stops, even if it crashed or never became ready. |
| `active.tags` | With `active.enabled`, the service starts only when a selected scenario has one of these tags. |
| `active.uses` | With `active.enabled`, the service starts only when a selected scenario uses a step of one of these packs, like `[mobile-android]`. |
| `debug` | `command`, `debugger` (`type`, `port`, `host`, `module`, `mode`), `onUnavailable` and `retry` for `axx run --debug` and `axx up --debug`. |
### lint
[Section titled “lint”](#lint)
Rules live under `lint.rules`; `lint.config` sets `baseDir` (patterns are relative to it) and the default `mode` (`error` fails, `warn` only reports); `lint.include` merges rule files such as `axx-lint.generated.yaml`. Each rule has:
| Key | Meaning |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`, `description` | Shown in the report. |
| `filePatterns` | Globs (`*`, `**`, `?`, `[abc]`, `{a,b}`), relative to `baseDir`. A leading `../` reaches outside it. The part before the first wildcard is a directory, and the rest matches the end of each path below it: `seeds/*.yaml` also finds YAML files in subdirectories of `seeds`. A pattern that ends in `/` matches folders, and all the files below each folder it matches count as one file: the folder of a scenario whose data spans several files (`seeds/*/`). |
| `excludePatterns` | Globs removed from the match, against the whole path relative to `baseDir`, for example `**.fixture.yaml`. |
| `type` | `regex` (default) or `jsonpath`. |
| `regex` | The first capture group that matched is the value. |
| `jsonPath` | A path in each file’s tree: `order.id`, `payments[0].id`, `payments[*].id`, an optional `$.` prefix, or any Jayway JsonPath. Files are read by their extension: JSON, YAML (`.yaml`, `.yml`), XML and CSV. A seed’s rows read alike in YAML, flat XML and CSV (`$['public.closures'][*]['id']`); in other XML, attributes are `@name` fields and an element with only text is a value. |
| `validation` | `global-unique` (default): every occurrence is unique. `file-unique`: unique within each file. `cross-file-unique`: may repeat inside a file, never across files. A folder a folder pattern matches counts as one file. |
| `mode` | Overrides the default mode for this rule. |
| `ignoreValues` | Values that are shared on purpose. |
### Finding and merging files
[Section titled “Finding and merging files”](#finding-and-merging-files)
1. With `-c path/to/axx.yaml`, that file. Otherwise Axx looks for `axx.yaml` (or `axx.yml`) in the working directory, then in each parent directory up to the repository root.
2. With `--profile NAME` or `AXX_PROFILE=NAME`, `profiles.NAME` is merged over the file, and then `axx.NAME.yaml` next to it, if either exists. Several profiles, comma-separated (`--profile ios,watch`), are merged in that order, each over the ones before: where they set different keys you get both, and where they set the same key the later one wins. A profile that exists in neither place is an error ([`AXX-E0104`](/next/references/error-codes/#axx-e0104)).
3. `axx.local.yaml` next to `axx.yaml`, if present, is merged last. Keep it out of version control for personal settings.
4. `--set path.to.key=value` sets any key for one run, over all of the above: `--set run.workers=1 --set packs.web-core.watch=true`. The value is read as YAML.
5. `-D name=value` overrides `properties`.
Paths inside the file (`services.*.dir`, `resources`) are relative to the directory of `axx.yaml`.
### Interpolation
[Section titled “Interpolation”](#interpolation)
String values can reference the environment and properties:
| Syntax | Value |
| ---------------------- | -------------------------------------------------------------------------- |
| `${env:NAME}` | environment variable `NAME` |
| `${sys:name}` | property `name` |
| `${sys:name:-default}` | `default` when `name` is not set; defaults can nest (`${sys:a:-${env:B}}`) |
| `$${...}` | a literal `${...}` |
The same syntax works in feature files: in service tables and step arguments.
### Errors
[Section titled “Errors”](#errors)
A syntax error or an invalid value stops every command with exit code `2` and points at the line: [`AXX-E0101`](/next/references/error-codes/#axx-e0101) for YAML syntax and [`AXX-E0102`](/next/references/error-codes/#axx-e0102) for values the schema rejects.
## axx-packs.yaml
[Section titled “axx-packs.yaml”](#axx-packsyaml)
The packs a project uses: `core`, which is always there, and the listed packs. Without the file, a project has no steps. `axx pack add`, `remove` and `new` edit it, and `axx init` creates it.
axx-packs.yaml
```yaml
packs:
- rest # one of Axx's packs, by name
- sql
- ./steps # a pack in this repository (a Go package)
- github.com/team/axx-grpc@v1.2.0 # a pack from a Go module; the version is optional
```
`axx-packs.lock` pins the versions of module packs; commit both files. [Choose packs](/next/guides/use-packs/) shows the commands.
# Exit and error codes
> The process exit codes, and every AXX-Exxxx error code with what it means and how to fix it.
Every user-facing error has a stable code. `axx explain AXX-E0102` prints an entry from this page; with `--json`, errors appear in `errors[]` with `code`, `message`, `location`, `hint` and `docs`.
## Exit codes
[Section titled “Exit codes”](#exit-codes)
| Code | Meaning |
| ---- | --------------------------------------------------------------------------------- |
| 0 | Success: every selected scenario passed (or the command succeeded). |
| 1 | At least one scenario failed. |
| 2 | Usage or configuration error: bad flags, invalid `axx.yaml`, unparsable features. |
| 3 | Undefined or ambiguous steps, or lint violations. |
| 4 | Environment or lifecycle failure: a service did not start, become ready or stop. |
| 5 | Reserved (not used). |
| 130 | Interrupted (Ctrl-C); services were stopped and cleaned up. |
## Command line
[Section titled “Command line”](#command-line)
### AXX-E0001
[Section titled “AXX-E0001”](#axx-e0001)
**Invalid usage** · exit 2
The command line could not be parsed: an unknown command or flag, a missing flag value, or the wrong number of arguments.
**Fix:** Run the command with `--help` to see its flags and arguments.
### AXX-E0002
[Section titled “AXX-E0002”](#axx-e0002)
**Invalid -D property** · exit 2
A `-D` flag is not in `name=value` form.
**Fix:** Pass properties as `-D name=value` (repeat the flag for several properties).
### AXX-E0004
[Section titled “AXX-E0004”](#axx-e0004)
**Cannot start the run** · exit 2
The run options are inconsistent, for example an unknown `--order` value or a reporter whose output cannot be created.
**Fix:** Check the message for the offending option; `axx run --help` lists the valid values.
### AXX-E0005
[Section titled “AXX-E0005”](#axx-e0005)
**Missing output directory** · exit 2
`axx docs export` needs to know where to write.
**Fix:** Pass `--out `.
### AXX-E0007
[Section titled “AXX-E0007”](#axx-e0007)
**Invalid skills scope** · exit 2
`axx skills install --scope` accepts `project` (the repository) or `user` (your home directory).
**Fix:** Use `--scope project` or `--scope user`.
### AXX-E0009
[Section titled “AXX-E0009”](#axx-e0009)
**Unknown error code** · exit 2
`axx explain` was given a code that is not in the catalog.
**Fix:** Check the code; every code is listed on the error-codes reference page.
### AXX-E0010
[Section titled “AXX-E0010”](#axx-e0010)
**Unknown schema kind** · exit 2
`axx schema --kind` accepts config (axx.yaml) or one of the fixture spec kinds: factory, fixture, prototype.
**Fix:** Use `--kind config`, `--kind factory`, `--kind fixture` or `--kind prototype`.
### AXX-E0011
[Section titled “AXX-E0011”](#axx-e0011)
**Invalid pack argument** · exit 2
`axx pack` was given a pack that is not the name of one of Axx’s packs, an existing local directory or a Go module path, or one that is not listed.
**Fix:** `axx pack list` lists Axx’s packs; other packs are added by path (`./steps`, created with `axx pack new`) or Go module path (`github.com/team/axx-grpc@v1.2.0`).
### AXX-E0012
[Section titled “AXX-E0012”](#axx-e0012)
**Unknown agent or scope** · exit 2
`axx mcp install` connects one coding agent (`--agent claude`, `codex`, `cursor`, `vscode` or `gemini`) in one scope: `project` (the repository’s own files) or `user` (your home directory).
**Fix:** Pass `--agent` with one of the listed agents, and `--scope project` (the default) or `--scope user`.
### AXX-E0013
[Section titled “AXX-E0013”](#axx-e0013)
**Agent configuration cannot be merged** · exit 2
Axx adds its MCP server to an agent’s configuration file only when it can read the file back exactly and keep everything else in it. The file has comments, is not valid JSON, or (Codex) lists its MCP servers in a form a new `[mcp_servers.axx]` table cannot join. Axx left it alone.
**Fix:** Add the server yourself: the hint shows the lines to add. Or fix the file and run the command again.
### AXX-E0014
[Section titled “AXX-E0014”](#axx-e0014)
**No packs to document** · exit 2
`axx docs export` found no packs to document: it was run outside an Axx project with an Axx that has no packs built in, or in a project that lists no packs.
**Fix:** Run it in your project’s directory (where axx.yaml is); Axx’s own packs are documented at . A project adds its packs with `axx pack add`.
### AXX-E0015
[Section titled “AXX-E0015”](#axx-e0015)
**No packs for the skills** · exit 2
`axx skills install` writes the skills with the steps of the project’s packs, and the project lists none: it has no `axx-packs.yaml` yet, or the file lists no packs.
**Fix:** Run `axx init` to set the project up: it installs the skills too. A project that is set up adds the packs its steps come from with `axx pack add`. `axx skills install --scope user` installs the skills for every project, with every pack Axx publishes.
### AXX-E0016
[Section titled “AXX-E0016”](#axx-e0016)
**A pack’s command failed** · exit 2
A command a pack adds to Axx (one `axx --help` lists in a project that uses the pack) failed, with an error of the pack’s own: the message is the pack’s.
**Fix:** The hint names the pack. Run the command with `--help` for what it takes, and see the pack’s reference.
## Configuration (axx.yaml)
[Section titled “Configuration (axx.yaml)”](#configuration-axxyaml)
### AXX-E0100
[Section titled “AXX-E0100”](#axx-e0100)
**Config file not found** · exit 2
The file given with `--config` does not exist or cannot be read.
**Fix:** Fix the path, or omit `--config` to let Axx search upward for `axx.yaml` (the file is optional).
### AXX-E0101
[Section titled “AXX-E0101”](#axx-e0101)
**Config is not valid YAML** · exit 2
`axx.yaml` (or a profile/local override) has a YAML syntax error; the message quotes the line.
**Fix:** Fix the indentation or quoting at the reported line. Values containing `: `or starting with `{`, `[`, `*` or `&` need quotes.
### AXX-E0102
[Section titled “AXX-E0102”](#axx-e0102)
**Invalid configuration** · exit 2
`axx.yaml` does not match the schema: an unknown key, a wrong type or an invalid value. Each problem is reported with its line.
**Fix:** Add `# yaml-language-server: $schema=https://axx.nimbusxr.us/schemas/v0/axx.schema.json` to get completion in your editor, or run `axx schema` to read the schema.
### AXX-E0104
[Section titled “AXX-E0104”](#axx-e0104)
**Unknown profile** · exit 2
`--profile` (or `AXX_PROFILE`) names a profile that is not defined under `profiles:` in `axx.yaml`.
**Fix:** Define the profile or pick one of the listed names.
### AXX-E0105
[Section titled “AXX-E0105”](#axx-e0105)
**Invalid –set** · exit 2
A `--set` is not a dotted path to a key of `axx.yaml` and a value.
**Fix:** Write `--set path.to.key=value`, like `--set run.workers=1` or `--set packs..=value`. The value is read as YAML, so `true` and `1` are a boolean and a number.
## Feature files and filters
[Section titled “Feature files and filters”](#feature-files-and-filters)
### AXX-E0200
[Section titled “AXX-E0200”](#axx-e0200)
**Feature file does not parse** · exit 2
A `.feature` file has a Gherkin syntax error.
**Fix:** Fix the reported line. `axx validate` checks every feature without running anything.
### AXX-E0201
[Section titled “AXX-E0201”](#axx-e0201)
**Feature path not found** · exit 2
A path given on the command line or in `run.paths` does not exist.
**Fix:** Check the path. Paths on the command line resolve from the working directory; `run.paths` resolve from the directory of `axx.yaml`.
### AXX-E0202
[Section titled “AXX-E0202”](#axx-e0202)
**Invalid tag expression** · exit 2
`--tags` (or `run.tags`) is not a valid Cucumber tag expression.
**Fix:** Use tags with `and`, `or`, `not` and parentheses, e.g. `--tags '@smoke and not @slow'`.
### AXX-E0203
[Section titled “AXX-E0203”](#axx-e0203)
**Invalid name filter** · exit 2
A `--name` pattern is not a valid regular expression.
**Fix:** Escape special characters, or pass a plain substring.
### AXX-E0204
[Section titled “AXX-E0204”](#axx-e0204)
**Line is not in a scenario** · exit 2
A `file:line` names a line that no scenario of the file contains, like the feature’s description or its Background, so it would select nothing.
**Fix:** Give the line of a scenario, of one of its steps or of an Examples row, or the file alone to run all of its scenarios.
### AXX-E0205
[Section titled “AXX-E0205”](#axx-e0205)
**Unknown pack in run.uses** · exit 2
`run.uses` lists a pack the project does not load, so no scenario could use it.
**Fix:** List packs of `axx-packs.yaml` (`axx pack list`), like `[mobile-ios]`, or add the pack with `axx pack add`.
### AXX-E0206
[Section titled “AXX-E0206”](#axx-e0206)
**Invalid shard** · exit 2
`--shard` is not a shard of a number of them, like `1/3`.
**Fix:** Give the shard and how many there are, from `1/n` to `n/n`: each runs every nth of the scenarios the run selects, so that together they run each one once.
## Packs, steps and resources
[Section titled “Packs, steps and resources”](#packs-steps-and-resources)
### AXX-E0300
[Section titled “AXX-E0300”](#axx-e0300)
**Pack cannot be loaded** · exit 2
A step pack failed to load or initialize: a step expression that does not compile, a duplicate parameter type, or pack configuration under `packs:` that is invalid.
**Fix:** Read the wrapped message. For a pack of your own, fix its step definitions; for Axx’s packs, check `packs.` in `axx.yaml`.
### AXX-E0301
[Section titled “AXX-E0301”](#axx-e0301)
**Resource not found** · exit 2
A file referenced by a step (seed, payload, schema, OpenAPI spec) was not found next to the feature file, in the configured `resources` roots, or next to `axx.yaml`.
**Fix:** Check the path in the step. Keep a file only one feature uses next to that feature file; for a file several features share, add its directory to `resources:` in `axx.yaml`.
### AXX-E0302
[Section titled “AXX-E0302”](#axx-e0302)
**Unknown pack** · exit 2
`axx-packs.yaml` lists a pack that Axx does not publish, or one this Axx was not prepared with, or the file cannot be read. Axx prepares itself with a project’s packs when it runs in the project.
**Fix:** Use the name of one of Axx’s packs (see `axx pack list`), a path to a pack in the project (`./steps`) or a Go module path; `axx pack add` checks entries for you. Run Axx from the project so it can prepare the packs.
### AXX-E0303
[Section titled “AXX-E0303”](#axx-e0303)
**Packs cannot be prepared** · exit 4
The first time Axx runs with a list of packs, it prepares itself with them, downloading what it needs once. The download failed.
**Fix:** Check the network: Axx downloads from go.dev and the Go module proxy (`GOPROXY` applies). Then run again; later runs start immediately.
### AXX-E0304
[Section titled “AXX-E0304”](#axx-e0304)
**Packs do not build** · exit 2
Preparing Axx with the project’s packs failed. The compiler output is in the message.
**Fix:** A pack is a Go package that exports `func Pack() core.Pack`. Fix the reported errors; `axx pack new ` scaffolds a working pack.
### AXX-E0305
[Section titled “AXX-E0305”](#axx-e0305)
**Delve is needed to debug step code** · exit 4
`axx run --debug-steps` runs Axx under Delve, Go’s debugger, so an IDE can stop at breakpoints in step code. Axx builds Delve itself the first time, from the Go module proxy, with the Go it downloads; it could not, and no `dlv` is installed.
**Fix:** Check the network and run again, or install Delve with `go install github.com/go-delve/delve/cmd/dlv@latest`, or set `AXX_DLV` to the path of a `dlv`.
### AXX-E0306
[Section titled “AXX-E0306”](#axx-e0306)
**Delve did not start** · exit 4
`axx run --debug-steps` started Delve, but it exited or never began listening for a debugger. Its output is in the message.
**Fix:** Check that the port is free (`--debug-steps=` picks another) and that Delve works on this machine (`dlv version`).
### AXX-E0310
[Section titled “AXX-E0310”](#axx-e0310)
**Unknown step** · exit 2
`axx steps show` was given text that is no loaded step’s id or expression and matches no step as a step line, or a line that matches several steps.
**Fix:** The hint names the closest steps. Show one by its id (`axx steps show rest.response.status`), list the ids with `axx steps`, or search by words with `axx steps search `.
## Service lifecycle
[Section titled “Service lifecycle”](#service-lifecycle)
### AXX-E0400
[Section titled “AXX-E0400”](#axx-e0400)
**Invalid service configuration** · exit 2
A `services.