# Agent and locators (/docs/agent-and-locators)
Two ways to write a step. Locators name an element exactly; the agent takes a plain-English instruction. Use both in one test.
A danwo test drives your app in two ways. A **locator** finds one element the way a user names
it, like "the button called Pay". The **agent** takes an instruction in plain English, like "pay
with the saved card", and works out the clicks itself.
```ts title="tests/checkout.danwo.ts"
import { test } from "@danwo/sdk"
test("pays for a basket", async ({ app, screen, agent, expect }) => {
await app.open("/basket")
await screen.getByRole("button", { name: "Checkout" }).click()
await agent.act("pay with the test card 4242 4242 4242 4242")
await expect(screen.getByRole("heading", { name: "Thank you" })).toBeVisible()
})
```
The first and last steps use locators. The payment page in the middle belongs to another company
and changes often, so the agent handles it.
## When to use which [#when-to-use-which]
| Use a locator when… | Use the agent when… |
| ------------------------------------------- | ----------------------------------------------------------------------- |
| you can name the element: a button, a field | the step is easier to say than to pin down |
| the control is stable and yours | the page is someone else's, like a provider's checkout |
| you check an exact value: a total, a count | you check what the page _means_: "the order is confirmed" |
| you want no model calls, ever | a recording can do the replays (see [Replay cache](/docs/replay-cache)) |
Locators never call a model. Agent steps call one the first time, then replay from a recording.
## Find an element with a locator [#find-an-element-with-a-locator]
`screen` finds elements by what a user sees. Prefer these, in this order:
```ts title="tests/sign-in.danwo.ts"
import { test } from "@danwo/sdk"
test("signs in", async ({ app, screen }) => {
await app.open("/login")
await screen.getByLabel("Email").fill("ada@shop.test")
await screen.getByRole("button", { name: "Sign in" }).click()
await screen.getByText("Welcome back").waitFor()
})
```
| Finder | Finds |
| --------------------------- | -------------------------------------------- |
| `getByRole(role, { name })` | a button, link, heading or field by its name |
| `getByLabel(text)` | a field by its label |
| `getByText(text)` | an element by the text it shows |
| `getByPlaceholder(text)` | a field by its placeholder |
| `getByTestId(id)` | an element by its `data-testid` |
A string matches part of the name, ignoring case. Pass `{ exact: true }` for an exact match, or a
regular expression.
## Act on it [#act-on-it]
Once a finder has one element, call an action on it. Each action waits for the element to be ready
first.
| Action | What it does |
| --------------------- | ---------------------------------- |
| `click()` | clicks the element |
| `longPress()` | presses it and holds, then lets go |
| `fill(text)` | types into a field, replacing it |
| `press(key)` | presses a key, like `"Enter"` |
| `check()` | ticks a checkbox or radio |
| `selectOption(value)` | picks an option in a select |
| `hover()` | moves the pointer over it |
| `setInputFiles(path)` | attaches a file to a file input |
| `dragTo(target)` | drags it onto another element |
## Hold a press [#hold-a-press]
Some controls do something else when you hold them: a message that opens a menu, a card you
start dragging. `longPress()` presses the element, holds it, then lets go. It waits for the element
like `click()` does.
```ts title="tests/messages.danwo.ts"
import { test } from "@danwo/sdk"
test("opens a message's menu", async ({ app, screen, expect }) => {
await app.open("/messages")
await screen.getByText("See you at noon").longPress({ durationMs: 600 })
await expect(screen.getByRole("menuitem", { name: "Reply" })).toBeVisible()
})
```
| Option | What it does | Default |
| ------------ | ------------------------------------------- | ---------------------------- |
| `durationMs` | how long the press is held, in milliseconds | 800 |
| `force` | skips the readiness checks (web only) | `false` |
| `timeout` | how long to wait for the element | the session's action timeout |
On a web page the mouse goes down on the middle of the element. On a phone the finger does.
## When an action keeps waiting [#when-an-action-keeps-waiting]
Component libraries often draw a checkbox or radio over an invisible ``. `check()` and
`uncheck()` handle that for you: when the input's own label covers it, danwo clicks the label, as
a user would, and then checks that the input changed.
For another control a user can reach but an action keeps waiting on, such as a button that never
stops animating, pass `{ force: true }`. The action then skips its readiness checks and acts at
once.
```ts title="tests/install.danwo.ts · force"
await screen.getByRole("button", { name: "Start now" }).click({ force: true })
```
`force` also clicks a disabled or covered element, so the test can pass where a user is stuck. Use
it only for the one control that needs it.
## Narrow it to one element [#narrow-it-to-one-element]
An action needs exactly one match. When a finder matches several, narrow it:
| Narrow with | Keeps |
| ------------------------- | ---------------------------------------- |
| `.first()` | the first match |
| `.nth(i)` | the match at index `i`, counting from 0 |
| `.filter({ hasText })` | the matches that contain this text |
| `.filter({ hasNotText })` | the matches that don't contain this text |
| `.filter({ has })` | the matches with this locator inside |
| `.filter({ hasNot })` | the matches without this locator inside |
| another `getBy…` call | elements inside the matches |
| `.and(other)` | the matches the other locator finds too |
```ts title="tests/basket.danwo.ts · narrowing"
await screen.getByRole("listitem").filter({ hasText: "Socks" }).getByRole("button").click()
await screen.getByRole("row").nth(2).click()
await screen.getByRole("row").filter({ hasNotText: "Archived" }).first().click()
```
When a locator finds nothing, the error says how many elements each call left, so you can see
which call lost it:
```text
locator.click: getByRole("listitem").filter(hasText: "Kiwi") matched nothing within 10000 ms; by step: getByRole("listitem") 3, .filter(hasText: "Kiwi") 0
```
## Match one of two [#match-one-of-two]
`.or(other)` matches what either locator finds. Use it when the page shows one of two things,
such as a dialog that only appears sometimes:
```ts title="tests/checkout.danwo.ts · or"
const pay = screen.getByRole("button", { name: "Pay" })
const cookies = screen.getByRole("button", { name: "Accept cookies" })
await expect(pay.or(cookies).first()).toBeVisible()
if (await cookies.isVisible()) await cookies.click()
await pay.click()
```
Not sure a locator matches one element? Your coding agent can check it against the running app
with the `check_locator` MCP tool, which answers with the exact line to paste.
## Next [#next]
The agent's four steps, with an example of each.
Why agent steps cost nothing after the first run.
# Set up your agent (/docs/agent-setup)
Which files danwo writes for each coding agent, and how to add the skill and MCP server by hand.
`danwo init` sets up your agent for you. This page shows what it writes for each one, so you can
check it, commit it, or do it by hand.
## The quick way [#the-quick-way]
Pass the agents you use to `--skills`, or pick them when `danwo init` asks:
```sh
npx @danwo/cli init --skills claude,cursor,codex,copilot
```
Each agent gets two things: **the skill**, a folder of instructions it reads, and **the MCP
server**, an entry in its MCP config that starts `npx @danwo/cli mcp`. Running `init` again changes
nothing, and your agents' other MCP servers are kept.
## Each agent [#each-agent]
Claude Code reads the skill from `.claude/skills/danwo/` and the server from `.mcp.json`:
```json title=".mcp.json"
{
"mcpServers": {
"danwo": { "command": "npx", "args": ["@danwo/cli", "mcp"] }
}
}
```
`danwo init --skills claude` writes both. Open Claude Code in the project and approve the `danwo`
server when it asks.
Cursor reads the server from `.cursor/mcp.json`. The skill goes in the shared
`.agents/skills/danwo/` folder:
```json title=".cursor/mcp.json"
{
"mcpServers": {
"danwo": { "command": "npx", "args": ["@danwo/cli", "mcp"] }
}
}
```
`danwo init --skills cursor` writes both. Turn the `danwo` server on in Cursor's MCP settings.
Codex reads the server from `.codex/config.toml`, and the skill from `.agents/skills/danwo/`:
```toml title=".codex/config.toml"
[mcp_servers.danwo]
command = "npx"
args = ["@danwo/cli","mcp"]
```
`danwo init --skills codex` adds this table and keeps every other table in the file.
Copilot's agent mode in VS Code reads MCP servers from `.vscode/mcp.json`, under `servers`:
```json title=".vscode/mcp.json"
{
"servers": {
"danwo": { "type": "stdio", "command": "npx", "args": ["@danwo/cli", "mcp"] }
}
}
```
`danwo init --skills copilot` writes it, and the skill to `.agents/skills/danwo/`. Start the
`danwo` server from VS Code's MCP view, then use it in Copilot Chat's agent mode.
`danwo init` doesn't write Windsurf's config, which lives in your home folder. Add the server to
`~/.codeium/windsurf/mcp_config.json` yourself:
```json title="~/.codeium/windsurf/mcp_config.json"
{
"mcpServers": {
"danwo": { "command": "npx", "args": ["@danwo/cli", "mcp"] }
}
}
```
Run `danwo init --skills agents` for the skill in `.agents/skills/danwo/`. Then refresh the server
list in Windsurf's MCP settings.
Any agent that speaks MCP can use danwo. Start the server over stdio from the project folder:
```sh
npx @danwo/cli mcp
```
`danwo init --skills agents` puts the skill in `.agents/skills/danwo/`, the folder many agents
share. If yours reads instructions from elsewhere, point it at `.agents/skills/danwo/SKILL.md`.
## Where each file goes [#where-each-file-goes]
| Agent | Skill | MCP config | Written by `init` |
| ------------------------ | ---------------------- | ------------------------------------- | ----------------- |
| Claude Code | `.claude/skills/danwo` | `.mcp.json` | yes |
| Cursor | `.agents/skills/danwo` | `.cursor/mcp.json` | yes |
| Codex | `.agents/skills/danwo` | `.codex/config.toml` | yes |
| GitHub Copilot (VS Code) | `.agents/skills/danwo` | `.vscode/mcp.json` | yes |
| Windsurf | `.agents/skills/danwo` | `~/.codeium/windsurf/mcp_config.json` | skill only |
| Any other | `.agents/skills/danwo` | your agent's MCP config | skill only |
## Keep the skill current [#keep-the-skill-current]
The skill is copied from the `@danwo/skill` package. When you upgrade danwo, run `danwo init`
again to refresh it.
If you've edited your copy of `SKILL.md`, `init` keeps your version and says so (`kept`). Delete
it to take danwo's again.
## Check it works [#check-it-works]
Ask your agent:
```text
Use danwo to list this project's tests.
```
It should call the `list_tests` tool and answer with your test files. If it can't find the tool,
restart the agent so it reloads its MCP config.
## Next [#next]
Every tool your agent can call, and when it uses each.
Hand a failing test to your agent and get a verified fix.
# Agent steps (/docs/agent-steps)
The agent's four calls, act, assert, extract and waitFor, with examples, parameters and limits.
The `agent` fixture gives a test four calls. Each takes plain English. Each is checked, recorded
the first time it passes, and replayed with no model call after that.
```ts title="tests/upgrade.danwo.ts"
import { test } from "@danwo/sdk"
test("upgrades to Pro", async ({ app, agent }) => {
await app.open("/settings/billing")
await agent.act("upgrade the workspace to the Pro plan")
await agent.assert("the plan shows as Pro")
})
```
## Pick a model [#pick-a-model]
Agent steps need a `model` in your config. A model id goes through the Vercel AI Gateway, which
reads `AI_GATEWAY_API_KEY` from the environment:
```ts title="danwo.config.ts · the model"
model: "anthropic/claude-sonnet-5",
```
[`danwo init`](/docs/quickstart) can set up Anthropic, OpenAI, Google, OpenRouter or any
OpenAI-compatible server instead.
### How much of a page the model reads [#how-much-of-a-page-the-model-reads]
The model reads the page as text. A long page is cut to fit the model, so a small model still has
room for the instruction and its answer:
| Model's input window | Page text it reads, at most |
| --------------------------------------- | ---------------------------------------------- |
| 128k tokens or more, or a model unknown | 120 000 characters (about 30k tokens) |
| Smaller, such as 32k tokens | a quarter of it: 32 000 characters (8k tokens) |
| The smallest windows | 8 000 characters |
A step whose page was cut says so in its record, with how long the page was. If a step misses
something far down a long page, scroll or filter the page first.
## `act`: do something [#act-do-something]
`act` carries out an instruction on the page: clicks, typing, choosing, scrolling, across several
pages if needed.
```ts title="tests/sign-up.danwo.ts"
import { test, unique } from "@danwo/sdk"
test("signs up", async ({ app, agent, secret }) => {
await app.open("/")
await agent.act("sign up as {email} with the password {password}", {
params: { email: unique("ada@shop.test"), password: secret("password") },
})
})
```
Values in braces come from `params`:
- `unique(value)` gives a fresh value on every run, such as a new email address. A replay accepts
the new value.
- `secret(name)` passes a configured [secret](/docs/secrets). The model only ever sees the name.
## `assert`: check what the page means [#assert-check-what-the-page-means]
`assert` asks a judge whether a claim is true of the page right now. It throws when it isn't:
```ts title="tests/order.danwo.ts · assert"
await agent.assert("the order is confirmed and shows one item")
await agent.assert("the status says paid", { poll: true })
```
With `{ poll: true }`, the judge looks again until the claim holds or time runs out. Use it when
the page gets there on its own, like after a webhook.
On replay, `assert` waits for the text that proved the claim last time, with no model call. A
claim can stop holding while that text is still shown, such as "the total matches the items"
after a pricing change. For a claim like that, ask the judge on every run:
```ts title="tests/order.danwo.ts · a claim judged every run"
await agent.assert("the total matches the items in the cart", { replay: false })
```
It costs a judge call on every run, and fails under `--replay-only`.
## `extract`: read typed values [#extract-read-typed-values]
`extract` reads values off the page and checks them against a schema. Use it for numbers you then
compare with the backend:
```ts title="tests/balance.danwo.ts"
import { test, z } from "@danwo/sdk"
test("shows the balance", async ({ app, agent, expect }) => {
await app.open("/account")
const { balance } = await agent.extract("the account balance", z.object({ balance: z.number() }))
expect(balance).toBe(500)
})
```
The values come from the elements the model points at, read by the runner, not typed by the model.
## `waitFor`: wait for a state [#waitfor-wait-for-a-state]
`waitFor` waits until a condition holds, then returns:
```ts title="tests/order.danwo.ts · waitFor"
await agent.waitFor("the order shows as paid", { timeout: 20_000 })
```
On replay, it waits for the text that proved the condition last time, with no model call.
## Options [#options]
| Option | On | What it does | Default |
| --------- | --------------------- | ------------------------------------------------------------------- | ------------- |
| `params` | act, extract, waitFor | Values the instruction names in braces. | none |
| `files` | act | Files the step may upload, by name. | none |
| `budget` | act | Limits for the step: turns, model calls, cost, time. | the session's |
| `vision` | act, assert, extract | Whether the step may see a screenshot: `true`, `"auto"` or `false`. | `"auto"` |
| `time` | act | Lets the step move time forward, e.g. "wait until the trial ends". | `false` |
| `poll` | assert | Ask again until the claim holds. | off |
| `replay` | assert | `false` asks the judge on every run and keeps no recording. | `true` |
| `timeout` | waitFor | How long to wait, in milliseconds. | 30 000 |
With `vision: "auto"`, the agent is told when part of a page isn't in its text view, such as a
chart or an icon button with no name. It can then look at a screenshot. Those steps replay with no
model too.
## Icon buttons with no name [#icon-buttons-with-no-name]
A button that holds only an icon has no name for the agent to read. When the icon's markup names
it, such as a `lucide-trash` class or a `trash.svg` image, the agent's view shows the word:
```text title="the agent's view · a row with a delete icon"
- listitem [ref=e7]:
- text: Invoice 43
- generic [ref=e9] [cursor=pointer] [icon="trash"]
```
The agent clicks it, and the recording finds it again by its icon. An icon with no such markup is
left for a screenshot. Giving the button an `aria-label` is better still: people using a screen
reader need the name too.
## Next [#next]
How a recorded step replays, and what each outcome means.
Keep passwords and keys away from the model.
# Android builds (/docs/android-build)
Build an Expo app as a release APK that trusts danwo's proxy, and reuse it until its sources change.
An Android app trusts only the system's certificates unless its build says otherwise. danwo's
proxy has its own certificate, created for each run. `danwo build-android` builds your Expo app so
it trusts it.
## Build it [#build-it]
```sh
npx @danwo/cli build-android app
```
`app` is the folder of your Expo project. danwo:
1. runs `expo prebuild`;
2. adds a network security config that also trusts user-installed certificates, where danwo puts
each run's;
3. runs `gradlew assembleRelease` with `DANWO=1`, using Android Studio's JDK when `JAVA_HOME` is
unset.
The JavaScript is bundled into the APK, so no Metro server runs during tests. Gradle runs with
`--no-daemon` and compiles Kotlin in its own process, so the build leaves no Gradle or Kotlin
daemon running.
## Reuse it [#reuse-it]
The APK is kept under `/.danwo/builds/`, keyed by a hash of the app's sources. An
unchanged app isn't built again. Force a rebuild with `--force`.
## Build it from the config [#build-it-from-the-config]
`buildAndroid` from `@danwo/expo` does the same inside your environment, so a test run always has
a current build:
```ts title="build.ts"
import { buildAndroid } from "@danwo/expo"
export async function currentApk(): Promise {
const build = await buildAndroid({ project: "app" })
return build.apk
}
```
This build trusts user-installed certificates. Use it only for testing; ship your store builds
without it.
## Next [#next]
Install the build on the emulator and test it.
# Android tests (/docs/android-tests)
Bind an emulator to the run, then write the same locators, checks, agent steps and invariants as on the web.
An Android test reads like a web test. `screen` finds elements on the device's screen,
`expect` waits for checks, and an invariant compares your backend with Stripe. danwo boots the
emulator, routes its traffic through the proxy, and installs your build.
## A test on a device [#a-test-on-a-device]
```ts title="tests/buy-credits.danwo.ts"
import type { DeviceFixtures } from "@danwo/device"
import { type StripeFixtures, test as base } from "@danwo/sdk"
const test = base.extend()
test("buys 500 credits", async ({ screen, expect, device }) => {
await screen.getByTestId("buy").tap()
/* Checkout opens in a Custom Tab; the same locators read the web page. */
expect((await device.foreground()).browser).toBe(true)
await screen.getByLabel("Email").fill("buyer@example.com")
await screen.getByRole("button", { name: "Pay $5.00" }).tap()
await expect(screen.getByTestId("credits")).toHaveText("Credits: 500")
})
```
`getByTestId` matches React Native's `testID`. A Custom Tab, the in-app browser a checkout often
opens, is part of the screen.
Every tap waits for its element to be enabled, so a button that is greyed out while the app loads
is tapped once it turns on. Just before it presses, danwo reads the screen again and finds the
element there, so a form the keyboard slid up is tapped where it is now.
## Bind the emulator in the config [#bind-the-emulator-in-the-config]
`withAndroidDevice` wraps your environment and binds an emulator to it:
```ts title="danwo.config.ts · the device"
return withAndroidDevice(environment, {
avd: "danwo_spike",
worker: workerIndex,
snapshot: "danwo_clean",
app: { package: "com.danwo.shopmobile", apk: build.apk, scheme: "danwoshop" },
})
```
The full file, with the backend and Stripe behind the proxy, is in
[`examples/shop-mobile`](/docs/examples/shop-mobile). For each worker, danwo:
1. boots the AVD headless, or uses one that already runs;
2. runs `adb root` and puts the run's certificate in the trust store;
3. turns off animations, Chrome's first-run screens and the captive-portal probe;
4. routes the device's traffic through the proxy;
5. installs the APK.
| Option | What it does |
| ------------- | ------------------------------------------------------------------- |
| `avd` | the AVD to boot |
| `worker` | gives each worker its own emulator (console port 5554 + 2 × worker) |
| `snapshot` | boots from a saved AVD snapshot: seconds instead of a minute |
| `serial` | uses a device that already runs, as it is |
| `permissions` | runtime permissions to grant before each launch |
| `app.scheme` | the link scheme `app.open(path)` uses, as `://` |
Pass `blocked: ANDROID_BACKGROUND_HOSTS` from `@danwo/device` to your services. It blocks the
emulator's own background traffic (Chrome sync, Play services), so the matrix never faults it.
## What a test can do [#what-a-test-can-do]
| Fixture | On Android |
| ---------- | ------------------------------------------------------------------------------------------------------------------- |
| `screen` | `getByTestId`, `getByRole`, `getByLabel`, `getByText`; `tap`, `fill`, `check`, `press`, `selectOption`, `longPress` |
| `expect` | the same checks as the web, including `toHaveScreenshot` |
| `app.open` | opens a deep link; with no path, restarts the app |
| `device` | `foreground()`, `back()`, `openLink(url)`, `logs()`, `swipe()`, `dismissKeyboard()` |
| `agent` | `act`, `assert`, `extract`, `waitFor`, with device tools |
Every attempt starts the app afresh: its data cleared, its permissions granted.
## Swipe and put the keyboard away [#swipe-and-put-the-keyboard-away]
`device.swipe` drags one finger across the screen. A slow swipe turns a carousel's page or pulls a
list to refresh:
```ts title="tests/feed.danwo.ts"
import type { DeviceFixtures } from "@danwo/device"
import { test as base } from "@danwo/sdk"
const test = base.extend()
test("turns the carousel and refreshes the feed", async ({ screen, expect, device }) => {
/* The finger moves left, so the carousel shows its next page. */
await device.swipe({ direction: "left", durationMs: 600 })
await expect(screen.getByText("Page 2")).toBeVisible()
/* Put the keyboard away, then pull the list down to refresh it. */
await device.dismissKeyboard()
await device.swipe({ direction: "down", durationMs: 900 })
})
```
| Option | What it does | Default |
| ------------ | --------------------------------------------------------- | ---------------- |
| `direction` | the way the finger moves: `up`, `down`, `left` or `right` | |
| `within` | the box to swipe across, in screenshot pixels | the whole screen |
| `from`, `to` | the two points to swipe between, in place of `direction` | |
| `durationMs` | how long the finger takes | 300 |
`dismissKeyboard()` does nothing when no keyboard shows. To hold an element, call `longPress()`
on its locator, as on the web:
```ts title="tests/feed.danwo.ts · long press"
await screen.getByTestId("post").longPress({ durationMs: 900 })
```
It waits for the element to be enabled, like `tap()`, and holds for 800 ms by default. The agent
has the same gestures.
## The agent on a device [#the-agent-on-a-device]
Plain-English steps work too. The agent reads the screen as a list of elements and acts with
taps, typing, scrolls, swipes and long presses. It sees which elements are disabled, checked or
selected, such as the active tab:
```ts title="tests/agent-buy.danwo.ts"
import { test, z } from "@danwo/sdk"
test("buys 500 credits when asked in plain words", async ({ agent, screen, expect }) => {
const { credits } = await agent.extract("the credits balance", z.object({ credits: z.number() }))
await agent.act("Start buying 500 credits")
await agent.act("Pay for the credits with the email {email}", {
params: { email: "buyer@example.com" },
})
await expect(screen.getByTestId("credits")).toHaveText(`Credits: ${credits + 500}`)
})
```
The first run records each step. Later runs, and every matrix trial, replay them with no model
call. In the example shop, the recorded run made 6 model calls in about 17 seconds; the replay
made none, in about 6.
## Next [#next]
Run the matrix on two or three emulators at once.
When to use plain-English steps.
# Your app's data (/docs/app-data)
Read and seed your app's own database from a test, so invariants check what was saved and not only what the screen showed.
Your tests can query your app's database directly. A small helper runs SQL on the test database,
and you use it to check rows in an invariant, seed rows before a run and wait for rows a webhook
writes.
## Why read the database [#why-read-the-database]
An [invariant](/docs/invariants) is the rule the fault matrix checks after every trial. The screen
can say "Order placed" while the order row was never written, or was written twice. Only the
database knows what your app saved, so the strongest invariants compare its rows with what the
provider recorded.
```ts title="tests/one-order.danwo.ts"
import { test } from "@danwo/sdk"
import { sql } from "./db.ts"
test("places an order", async ({ app, screen }) => {
await app.open("/products/mug")
await screen.getByRole("button", { name: "Buy now" }).click()
}).invariant(async ({ expect }) => {
const orders = await sql("select id from orders where email = $1", ["ada@shop.test"])
expect(orders.length).toBeLessThanOrEqual(1)
})
```
## Write a query helper [#write-a-query-helper]
Put the helper in a file next to your tests. This one uses the `pg` driver and opens a connection
for each query:
```ts title="tests/db.ts"
import { Client, type QueryResultRow } from "pg"
export const TEST_DATABASE_URL =
process.env.TEST_DATABASE_URL ?? "postgres://postgres@127.0.0.1:5432/shop_test"
if (!new URL(TEST_DATABASE_URL).pathname.endsWith("_test"))
throw new Error(`not a test database: ${TEST_DATABASE_URL}`)
/** Runs one statement on the app's test database and returns its rows. */
export async function sql(
text: string,
values: readonly unknown[] = [],
): Promise {
const client = new Client({ connectionString: TEST_DATABASE_URL })
await client.connect()
try {
return (await client.query(text, [...values])).rows
} finally {
await client.end()
}
}
```
Install the driver with `npm install -D pg @types/pg`. A connection per query is slower than a
pool, but it never holds a connection that a [snapshot restore](#isolation-puts-the-rows-back)
has closed.
Point the helper at a database made for tests, never at production or a shared staging database.
Seeding empties tables, and the name check makes a wrong URL fail before the first query.
### Or use psql [#or-use-psql]
If your machine has `psql`, you can skip the driver. Send the SQL on standard input and pass each
value with `-v`, so psql quotes it for you:
```ts title="tests/psql.ts"
import { spawnSync } from "node:child_process"
const url = process.env.TEST_DATABASE_URL ?? "postgres://postgres@127.0.0.1:5432/shop_test"
/** Runs `text` with psql; `:'name'` in it reads `values.name`, quoted as a literal. */
export function psql(text: string, values: Readonly> = {}): string {
const vars = Object.entries(values).flatMap(([name, value]) => ["-v", `${name}=${value}`])
const result = spawnSync("psql", [url, "-v", "ON_ERROR_STOP=1", "-Atq", ...vars], {
input: text,
encoding: "utf8",
})
if (result.status !== 0) throw new Error(`psql failed: ${result.stderr}`)
return result.stdout.trim()
}
```
Call it as `psql("select count(*) from orders where email = :'email'", { email })`. It returns
psql's plain text: one row per line, columns split by `|`.
psql fills in variables only for SQL it reads from standard input or a file. With `-c`, a
`:'email'` is a syntax error.
## Keep values out of the SQL text [#keep-values-out-of-the-sql-text]
Pass every value as a parameter, never glue it into the string. A name with an apostrophe breaks a
glued query, and a hostile one runs as SQL.
```ts title="tests/db.ts · a parameter"
await sql("select status from orders where email = $1", [email])
```
```ts title="tests/db.ts · a glued value"
await sql(`select status from orders where email = '${email}'`)
```
Table and column names can't be parameters. Keep those as fixed text in your helpers.
## Check rows in an invariant [#check-rows-in-an-invariant]
The best invariant reads one fact from two sides. Here the shop's paid orders must match the
checkout sessions Stripe marked paid, whichever way the trial went:
```ts title="tests/checkout.danwo.ts"
import { type StripeFixtures, test as base } from "@danwo/sdk"
import { sql } from "./db.ts"
const test = base.extend()
test("pays for a mug", async ({ app, screen }) => {
await app.open("/products/mug")
await screen.getByRole("button", { name: "Buy now" }).click()
await screen.getByTestId("checkout-pay").click()
}).invariant(async ({ stripe, expect }) => {
const res = await stripe.fetch(
new Request("https://api.stripe.com/v1/checkout/sessions?payment_status=paid", {
headers: { authorization: "Bearer sk_test_any" },
}),
)
const { data } = (await res.json()) as { data: unknown[] }
const paid = await sql("select id from orders where status = $1", ["paid"])
expect(paid).toHaveLength(data.length)
expect(paid.length).toBeLessThanOrEqual(1)
})
```
A trial where the payment failed passes with zero on both sides. A trial where a retried webhook
marked two orders paid fails.
## Seed before a run [#seed-before-a-run]
Rows every test needs, like the products in the shop, go in at the top of `environment()`. That
runs once per worker, before the apps start:
```ts title="danwo.config.ts · seeding"
async environment() {
/* The last run's rows would end up in this run's snapshot. */
await sql("truncate orders, products restart identity cascade")
await sql("insert into products (sku, name, price_cents) values ($1, $2, $3)", ["mug", "Mug", 1200])
const postgres = await startPostgresProxy({ upstream: TEST_DATABASE_URL })
// start the stack with DATABASE_URL: postgres.url(TEST_DATABASE_URL), as on Databases
}
```
Empty the tables first. danwo keeps its snapshot after the stack starts, so anything left over
from the last run would be in every test's starting state.
## Seed for one session [#seed-for-one-session]
Rows that belong to one signed-in user go in that [session's](/docs/sessions) `setup`. danwo keeps
them in the session's own snapshot slot, beside its sign-in, when the environment sets
`snapshotSlots: true`:
```ts title="danwo.config.ts · a session"
sessions: {
"ada-with-address": {
async setup({ app, screen }) {
await sql(
"insert into addresses (email, line1, city) values ($1, $2, $3) on conflict do nothing",
["ada@shop.test", "12 Crescent Road", "London"],
)
await app.open("/login")
await screen.getByLabel("Email").fill("ada@shop.test")
await screen.getByRole("button", { name: "Sign in" }).click()
},
},
},
```
A test that names `ada-with-address` starts signed in with her address saved. A test without it
starts from the seeded state, with no address.
## Wait for rows written in the background [#wait-for-rows-written-in-the-background]
Before an invariant runs, danwo settles the stack. It waits until every webhook has been delivered
and every [background job](/docs/background-jobs) has finished. That covers most rows.
Some apps answer a webhook first and write the row a moment later. Then settling ends before the
row exists. Poll for it:
```ts title="tests/eventually.ts"
import { sql } from "./db.ts"
/** Waits up to `ms` for `text` to return a row, and returns the rows. */
export async function eventually(text: string, values: readonly unknown[] = [], ms = 10_000) {
const deadline = Date.now() + ms
for (;;) {
const rows = await sql(text, values)
if (rows.length > 0) return rows
if (Date.now() > deadline) throw new Error(`no rows within ${ms} ms: ${text}`)
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
```
Use it in an invariant only for a row that must appear. A row that may rightly be missing, like an
order after a declined card, would wait out the whole timeout.
## Isolation puts the rows back [#isolation-puts-the-rows-back]
With a [snapshot](/docs/stack-databases) in your environment, every test starts from the seeded
rows. What one test writes is gone before the next, so an invariant counts only its own test's
orders. See [Isolation](/docs/isolation) for the slots that keep each session apart.
| Rows written in | Seen by |
| --------------------------------- | ------------------------------------------- |
| `environment()`, before the stack | every test |
| a session's `setup` | every test that uses that session |
| a test's body or invariant | that test only; the next restore removes it |
The `recreate` and `swap` strategies close every connection to the database while they restore.
That's why the helper above opens a fresh one for each query.
## Your reads skip the faults [#your-reads-skip-the-faults]
The fault matrix breaks your app's database calls through danwo's **data proxy**: a small server
on its own port that passes statements on to Postgres. Only connections made to the proxy's URL go
through it. Nothing reroutes other traffic to the database.
| Who connects | To | Recorded and faulted |
| -------------------------- | --------------------- | -------------------- |
| your app | `postgres.url(url)` | yes |
| your helper in a test | the test database URL | no |
| danwo's `postgresSnapshot` | the test database URL | no |
So point the helper at the real database URL, never the proxy's. A trial's fault stays armed until
the trial ends, invariant included. Through the proxy, your reads could be the ones faulted, and
the clean trial would list them among the test's statements for the matrix to plan faults on.
## Next [#next]
Wire the proxy and the snapshot into your stack.
Rules that hold whichever way a trial went.
What the matrix does to your app's statements.
# Background jobs (/docs/background-jobs)
Route your app's Inngest events through danwo, wait for the runs they start, and fault the sends.
Your API sends an event, and a background job does the slow part later: provisioning an account,
sending a receipt. `inngestService` routes those events through danwo, so a test can wait for the
jobs to finish and the matrix can break the sends.
## Add Inngest to the stack [#add-inngest-to-the-stack]
Give `stackEnvironment` an `inngestService`, and give every app that sends or serves Inngest
functions its `env`:
```ts title="danwo.config.ts"
import { defineConfig, inngestService, stackEnvironment } from "@danwo/sdk"
export default defineConfig({
testDir: "tests",
readiness: { mode: "page+backend", settle: true },
async environment() {
const inngest = await inngestService({
start: true,
apps: ["http://localhost:4100/api/inngest"],
})
return stackEnvironment({
services: [inngest],
apps: [
{
name: "api",
command: "pnpm start",
url: "http://localhost:4000",
dependsOn: ["worker"],
env: { ...inngest.env },
},
{
name: "worker",
command: "pnpm worker",
url: "http://localhost:4100",
ready: { log: "worker ready" },
env: { ...inngest.env },
},
],
})
},
})
```
`inngest.env` turns on Inngest's dev mode and points the SDK at `inngest.danwo.test`. The proxy
routes that host to the dev server, so every event a test sends is recorded.
## Use a dev server you already run [#use-a-dev-server-you-already-run]
Leave out `start` and danwo uses a dev server at `url`:
| Option | What it does | Default |
| ---------------------- | ------------------------------------------------------------------ | ----------------------- |
| `url` | where the dev server answers | `http://127.0.0.1:8288` |
| `start` | `true` runs `npx --yes inngest-cli dev`; a string is your command | off |
| `apps` | your apps' Inngest endpoints, which the dev server calls | none |
| `settleTimeoutMs` | how long `settle()` waits for runs to finish | 30 seconds |
| `retryIntervalSeconds` | with `start: true`, the wait between the attempts of a failed step | 1 second |
A dev server danwo starts gets free ports for its connect gateway as well as for `url`, so each
worker of a run can start its own.
## Wait for the jobs [#wait-for-the-jobs]
An invariant often checks what a job wrote. Call `settle()` first: it waits until every run the
test's events started has finished.
```ts title="tests/tasks.danwo.ts"
import { test as base, z } from "@danwo/sdk"
const test = base.extend<{
readonly apps: Readonly>
readonly settle: () => Promise
}>()
const Provisioned = z.array(z.string())
test("adds a task", async ({ app, screen, expect }) => {
await app.open("/")
await screen.getByRole("button", { name: "Add task" }).click()
await expect(screen.getByRole("status")).toHaveText("Task saved")
}).invariant(async ({ apps, settle, expect }) => {
await settle()
const provisioned = await fetch(`${apps.worker ?? ""}/provisioned`)
expect(Provisioned.parse(await provisioned.json())).toEqual(["Write the report"])
})
```
With `readiness: { settle: true }`, each step also waits for the runs it started. The flight
record lists every run under `inngest`, with its function, status and duration.
A run that fails for good starts its function's `onFailure` handler, and settling waits for that
too, so an invariant can check what the handler did.
## After time travel [#after-time-travel]
[`time.travel`](/docs/time-travel) moves your apps' clocks, and Inngest keeps up:
- **Cron functions.** Each one that falls due on the way runs once, at the new time, the way a
dev server left asleep catches up when it wakes. `settle()` waits for that run and for the runs
its events start.
- **Events.** An event your app sends after the jump runs at once. The SDK stamps it with the
app's clock, days ahead, and the dev server would otherwise hold it until real time caught up.
```ts title="tests/digest.danwo.ts · a time travel"
test("sends the digest at nine", async ({ app, screen, expect, time }) => {
await app.open("/digests/new")
// …schedule it for tomorrow at 09:00…
await time.travel({ days: 1 })
await app.open("/digests")
await expect(screen.getByRole("row", { name: /Weekly notes/ })).toContainText("Sent")
})
```
danwo runs a due cron function the way the dev server's Invoke button does, so the run's event is
`inngest/function.invoked`, with no data. The v4 SDK checks that event against the schemas of the
function's typed event triggers, so a cron function that has one needs an `invoke()` trigger too:
```ts title="worker/functions.ts · the scheduler"
import { cron, invoke } from "inngest"
inngest.createFunction(
{ id: "scheduler", triggers: [cron("0 * * * *"), DigestScheduled, invoke(z.object({}))] },
async ({ step }) => {
/* …find what is due, then fan it out… */
},
)
```
A run already asleep in `step.sleep` or `step.sleepUntil` still wakes at real time: the dev server
keeps its own clock. For work you schedule, store when it is due and let a cron function pick it
up, or start the job from a provider's webhook, such as Stripe's trial-ending notice.
## Break the sends [#break-the-sends]
`danwo matrix` faults each event your app sends during the test:
```text
status 500 on POST inngest.danwo.test/e/dev
reset-after on POST inngest.danwo.test/e/dev
delay 3000ms on POST inngest.danwo.test/e/dev
```
| Fault | What your app meets |
| -------------- | ------------------------------------------------------------------------------ |
| `status 500` | the dev server is down |
| `reset-after` | the event was accepted, but the answer was lost; a retrying app sends it twice |
| `delay 3000ms` | a slow send |
An app that sends the event once and gives up on a 500 loses the job:
```text
FAIL status 500 on POST inngest.danwo.test/e/dev
invariant: expected [] to deeply equal [ 'Write the report' ]
```
The fix is to retry the send, or to write the job to your own outbox table first and send it from
there.
## Fail one step from a test [#fail-one-step-from-a-test]
danwo faults what crosses its proxy, so a step fails when the provider call inside it does. To
make that happen once in a plain test, arm the fault on the `proxy` fixture:
```ts title="tests/digest.danwo.ts · one step failing"
test("a send that loses its answer is retried once", async ({ app, proxy, time, settle }) => {
// The first email is accepted, but the step never hears back, so Inngest retries it.
proxy.failNext((call) => call.host === "api.resend.com" && call.path === "/emails", {
kind: "reset-after",
})
// …schedule the digest, then travel to its send time…
await settle()
})
```
`failNext(match, fault, times)` faults the next `times` calls that `match` (default one), ahead
of anything the matrix planned, and is disarmed when the test ends. An invariant then checks the
retry did no double work: one email, one delivery row.
## Next [#next]
A frontend, an API and this worker, tested with a matrix.
Every fault the matrix can plan.
# CI and GitHub (/docs/ci)
Run danwo in GitHub Actions and get a check on every pull request, with each failing line annotated.
Run your tests and a fault matrix on every pull request. danwo reports back as a **GitHub check**,
with an annotation on each failing test line, and keeps one comment on the pull request up to date.
## The workflow [#the-workflow]
```yaml title=".github/workflows/danwo.yml"
name: danwo
on: [pull_request]
permissions:
checks: write
pull-requests: write
contents: read
jobs:
danwo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 24 }
- run: npm ci
- run: npx @danwo/cli install-browser --with-deps --only-shell
- run: npx @danwo/cli test --reporter github
env:
GITHUB_TOKEN: ${{ github.token }}
- run: npx @danwo/cli matrix tests/checkout.danwo.ts --reporter github
if: ${{ !cancelled() }}
env:
GITHUB_TOKEN: ${{ github.token }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: danwo-runs
path: .danwo/runs
```
`--reporter github` turns on the check, the [pull request comment](/docs/pr-comment) and the job
summary. The last step uploads the flight records, so you can open a failure's recording.
## What the check shows [#what-the-check-shows]
- pass and fail counts, and a row per failing test or fault;
- an annotation on each failing test line, in the pull request's **Files changed** tab;
- each failure's flight record, by its path in the uploaded artifact.
The same summary goes to the job summary page.
## Permissions [#permissions]
| Permission | Needed for | Without it |
| ---------------------- | --------------------------------- | ------------------------------------------------ |
| `checks: write` | the check run and its annotations | the reporter says so; the exit code is unchanged |
| `pull-requests: write` | the pull request comment | the reporter says so once; the check still runs |
| `contents: read` | checking out the code | the job can't start |
## CI defaults [#ci-defaults]
With `CI` set (every hosted CI sets it), or with `--ci`, `danwo test`:
- retries a failing test once;
- reads the replay cache without writing to it;
- refuses `.only`, with exit code 2 naming the file and line.
Your config's `retries` and `--retries` still win.
## Make no model calls [#make-no-model-calls]
A read-only cache still calls the model when a step has no recording. To make sure CI spends
nothing, run with `--replay-only`:
```sh
npx @danwo/cli test --replay-only
```
Each agent step must then replay from the cache. A step that can't fails the test instead of
calling the model:
```text
✗ tests/checkout.danwo.ts › pays for an order [Undecided]
REPLAY_MISSING: agent.act("pay for the order") has no recording that replays (none was recorded), and replay-only runs no model. Run the test without --replay-only to record it, check it, and commit the cache.
4 passed, 1 failed (1 undecided)
cache: 11 replayed · 0 relocated · 0 handed off · 1 missed
model: 0 calls (replay-only)
```
| A step with | Under `--replay-only` |
| ---------------------------------- | ----------------------------------- |
| a recording that replays | replays, as always |
| no recording | fails with `REPLAY_MISSING` |
| a stale recording | fails, naming what changed |
| a recording that no longer matches | fails, saying what stopped matching |
To make every run replay-only, set it in the config:
```ts title="danwo.config.ts · the cache"
cache: { dir: ".danwo-cache", replayOnly: true },
```
Record a failing step by running it locally without the flag, then commit the cache.
## Bound the run [#bound-the-run]
Two flags stop a run from starting new test files. Files already running finish:
| Flag | Stops starting files | Exit code |
| -------------------- | -------------------------------------- | ------------------------ |
| `--max-failures ` | once `n` tests failed | `1`, from the failures |
| `--max-duration ` | once the run has taken `t`, e.g. `20m` | `1`: files were left out |
Run `npx @danwo/cli doctor` as a first step to see what a run would trip on: Node, Chromium, the
config, the model's key. `--json` prints the checks for a script.
## Exit codes [#exit-codes]
| Code | Meaning |
| ----- | ---------------------------------------------------------- |
| `0` | everything passed |
| `1` | a test failed, or a run could not be judged |
| `2` | configuration problem |
| `3` | infrastructure: the browser, environment or model provider |
| `130` | interrupted |
## Keep CI cheap [#keep-ci-cheap]
Commit the replay cache (`cache.dir`). CI then replays recorded agent steps with no model calls,
so it needs a model key only when a recording is missing. With `--replay-only` it never needs one.
Run a narrowed matrix on pull requests with `--faults`, and the full matrix on a schedule.
## Next [#next]
One comment per pull request, kept up to date.
Split a big suite across machines.
Run only the tests a change can affect.
# Overview (/docs/coding-agents)
Your coding agent can write danwo tests, run them, read why they failed and fix the cause, through danwo's skill and MCP server.
danwo comes with everything a coding agent needs to work with it: a **skill** that teaches it how
danwo tests are written, and an **MCP server** that lets it run tests, drive your app and read
flight records. It works with Claude Code, Cursor, Codex, GitHub Copilot and any agent that reads
skills or speaks MCP.
## Why give your agent danwo [#why-give-your-agent-danwo]
An agent that writes code can now check its own work the way a user would. It can open your app,
click through a flow, see which provider calls happened behind it, and run the fault matrix. When a
test fails, it reads the same failure page you would, and fixes the cause.
## Set it up in one command [#set-it-up-in-one-command]
`danwo init` asks which agents you use and sets each one up:
```sh
npx @danwo/cli init
```
```text
Coding agents
created .claude/skills/danwo/SKILL.md
created .agents/skills/danwo/SKILL.md
created .mcp.json
created .cursor/mcp.json
created .vscode/mcp.json
created .codex/config.toml
```
[Set up your agent](/docs/agent-setup) shows each agent's files, and how to add one by hand.
## What your agent gets [#what-your-agent-gets]
| Piece | What it is | Where it lives |
| ----------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------- |
| The skill | How to write a danwo test, run the matrix, and debug a failure. | `.claude/skills/danwo`, `.agents/skills/danwo` |
| The MCP server | Tools to run tests, drive the app step by step, and read records. | `npx @danwo/cli mcp`, registered in your agent's MCP config |
| `danwo spec` | Every API, config option, fault and error code, generated from danwo's code. | the CLI, and the `danwo://spec` MCP resource |
| `danwo guide` | Task guides: first test, providers, matrix, debugging and more. | the CLI, and `danwo://guide/` |
| The failure pages | Each failed test as one page of evidence, ready to paste into a prompt. | `danwo show --format md`, the dashboard |
The [MCP tools](/docs/mcp-tools) page lists every tool and what it's for.
## Ask it to write a test [#ask-it-to-write-a-test]
With the MCP server connected, ask in plain words:
```text
Write a danwo test for buying 500 credits through Stripe Checkout,
then run the matrix on it.
```
The agent opens a session in danwo's browser, walks the flow, checks each locator, scaffolds the
test with an invariant, runs it, and fixes it until it passes. The server's `write-danwo-test`
prompt gives it those steps.
The agent never sees your secrets. It fills a password with `fill_secret` by the secret's name,
and every answer, screenshot and record is redacted.
## Next [#next]
Claude Code, Cursor, Codex, Copilot or Windsurf: what goes where.
From a red test to a fix your agent has verified.
# Common findings (/docs/common-findings)
The bugs the fault matrix finds most often, what each looks like, and how to fix it.
Most matrix failures come from a handful of patterns. Each one below shows the failing trial,
what went wrong, and the usual fix.
## A webhook handled twice [#a-webhook-handled-twice]
```text
FAIL duplicate on webhook checkout.session.completed
invariant: expected 1000 to be 500
```
**What happened.** Providers deliver webhooks **at least** once, so the same event can arrive
twice. Your handler granted the credits both times.
**The fix.** Store each event's id when you handle it, and skip an id you've seen:
```ts title="src/webhooks.ts"
const handled = new Set()
export function onCheckoutCompleted(event: { id: string }, grant: () => void): void {
if (handled.has(event.id)) return
handled.add(event.id)
grant()
}
```
In production, keep the ids in your database, in the same transaction as the work.
## A payment made twice [#a-payment-made-twice]
```text
FAIL double-submit on POST /buy
invariant: expected 2 to be 1
```
**What happened.** The user clicked **Pay** twice, or the page sent the request twice. Your app
created two payments.
**The fix.** Send Stripe an **idempotency key** that's the same for both requests, such as the
order id. Stripe returns the first payment instead of making a second one.
## An answer lost after the work was done [#an-answer-lost-after-the-work-was-done]
```text
FAIL reset-after on POST api.stripe.com/v1/payment_intents
invariant: expected 2 to be 1
```
**What happened.** Stripe took the payment, but the connection dropped before your app got the
answer. Your app retried, and paid again.
**The fix.** The same as a double submit: an idempotency key on every call that moves money, so
a retry is safe.
## A lost commit [#a-lost-commit]
```text
FAIL reset-after on COMMIT (postgres)
invariant: expected 0 to be 1
```
**What happened.** Postgres committed the order, but your app never heard back. It showed an
error, or tried again and created a second order.
**The fix.** After a lost commit, check whether the row exists before writing it again. Or make
the write idempotent with a unique key, such as an `ON CONFLICT` clause.
## A failure shown as success [#a-failure-shown-as-success]
```text
FAIL status 500 on POST api.resend.com/emails
invariant: expected 0 to be 1
```
**What happened.** Sending the confirmation email failed, but the page still said "Check your
inbox".
**The fix.** Treat the provider's error as an error: retry it in the background, or tell the
user it didn't go out.
## Next [#next]
Catch these with a rule about your backend.
Some of these, such as a missing idempotency key, are flagged on every run.
# Configuration (/docs/configuration)
A tour of danwo.config.ts, grouped by what each option is for.
`danwo.config.ts` default-exports one object. Every key is optional; `defineConfig` only types it.
```ts title="danwo.config.ts"
import { defineConfig, stripeEnvironment } from "@danwo/sdk"
export default defineConfig({
testDir: "tests",
workers: 2,
retries: 1,
model: "anthropic/claude-sonnet-5",
readiness: { mode: "page+backend", settle: true },
video: "retain-on-failure",
environment: () => stripeEnvironment(),
})
```
This page groups the options. [Reference: Config](/docs/reference/config) lists each one with its
type.
The config loads like a test file: Node removes its types and compiles nothing, so an `enum` or a
`namespace` won't load. [TypeScript in tests](/docs/typescript) lists what works, imports
included.
Types aren't checked either, so a misspelt key would do nothing. danwo warns about each key it
doesn't know, and suggests the one you likely meant:
```text
danwo: warning: unknown config key `retires` (did you mean `retries`?); danwo ignores it
```
## Finding and running tests [#finding-and-running-tests]
| Option | Default | What it does |
| ------------ | ------------------- | -------------------------------------------------------------- |
| `testDir` | the config's folder | where test files are found |
| `testMatch` | every `*.danwo.ts` | globs relative to `testDir` |
| `workers` | `1` | worker processes, each with its own environment |
| `splitFiles` | `false` | hand workers single tests, not whole files, where that is safe |
| `retries` | `0` | extra attempts for a failing test |
| `timeout` | `60_000` | one attempt's deadline, in milliseconds |
| `baseUrl` | none | the app's URL when no environment gives one |
### Split slow files across workers [#split-slow-files-across-workers]
A worker runs one test file at a time, so one long file can keep one worker busy while the others
sit idle. `splitFiles: true` (or `danwo test --split-files`) hands out single tests instead.
danwo only splits a file whose tests share nothing: no `beforeAll` or `afterAll`, and every test on
`isolation: "test"`. Other files still run whole. Each test loads its file again, so tests can't
pass values to each other through variables at the top of the file.
## What tests run against [#what-tests-run-against]
| Option | Default | What it does |
| ------------- | -------- | ----------------------------------------------------------------------------- |
| `environment` | none | starts the app, emulators and proxy, once per worker |
| `isolation` | `"test"` | reset to the seeds before every test, once per `"file"`, or `"none"` |
| `sessions` | none | sign-ins set up once per worker and reused ([Saved sessions](/docs/sessions)) |
| `secrets` | none | values the model never sees ([Secrets](/docs/secrets)) |
| `files` | none | files tests may upload, by name |
| `browser` | none | the browser's locale, time zone, size and more ([Web](/docs/web)) |
## Waiting [#waiting]
| Option | Default | What it does |
| ----------- | ----------------------------- | -------------------------------------------------------- |
| `readiness` | `"page+backend"` with a proxy | after each action, wait for the page and the app's calls |
| `appErrors` | `{ console: false }` | `console: true` also fails a step on console errors |
## The agent [#the-agent]
| Option | Default | What it does |
| ----------------- | --------------- | ------------------------------------------- |
| `model` | none | an AI SDK model, or a Vercel AI Gateway id |
| `judge` | `model` | the model `agent.assert` judges with |
| `judgeScreenshot` | `false` | show the judge a screenshot too |
| `vision` | `"auto"` | when the agent may see the page as an image |
| `budget` | 25 turns a step | limits on turns, calls, dollars and time |
## Replays [#replays]
| Option | Default | What it does |
| ------------ | -------------- | ------------------------------------------------------------- |
| `cache` | none: no cache | `{ dir }`, where the [replay cache](/docs/replay-cache) lives |
| `relocation` | `"similar-v1"` | find an element that moved, or `"exact"` never does |
| `effects` | `true` | a replayed step must change what its recording changed |
## Records and reports [#records-and-reports]
| Option | Default | What it does |
| ----------- | ------------------- | --------------------------------------------------------------------------------------- |
| `record` | `"always"`, 50 runs | a [flight record](/docs/flight-records) mode, or `{ mode, keepRuns }` to keep more runs |
| `video` | `"on"` | keep video with each record, `"retain-on-failure"`, or `"off"`; `{ mode, cursor }` too |
| `reporters` | none | e.g. `github()` from `@danwo/github` ([CI](/docs/ci)) |
| `lint` | on, never fails | [integration lint](/docs/integration-lint) levels and `failOn` |
| `impact` | none | rules for [changed tests](/docs/impact) |
## The fault matrix [#the-fault-matrix]
`matrix` holds the matrix's own settings:
| Option | Default | What it does |
| ---------------- | --------------------------------------------------------------------- | ---------------------------------------------------------- |
| `budget` | `40` | most faulted trials a matrix runs |
| `workers` | `workers`, else one per core (up to 8) | matrix worker processes |
| `races` | all on | double submits, early and reordered webhooks, slow retries |
| `outboundFaults` | each provider's own | the faults planned on each provider call |
| `webhookFaults` | the defaults | the faults planned on each webhook |
| `dataFaults` | the defaults | faults on the app's database and cache calls |
| `rerunInvalid` | `0` | reruns of a trial whose fault never fired |
| `checkPace` | 3× the clean run, at least 1 s; on a device 6 s, once the fault fired | how long a faulted run's checks wait |
See [Planning and budget](/docs/matrix-planning) for how the budget is spent.
## Next [#next]
Every option, with its type.
An `environment` for a real stack.
# Dashboard (/docs/dashboard)
Browse every run on your machine, see which tests break under which faults, and open any recording.
`danwo serve` opens a dashboard of your project's runs: every test, every matrix, every recording.
It reads what danwo already keeps in `.danwo`, on your machine. Nothing leaves it.
## Open it [#open-it]
```sh
npx @danwo/cli serve --open
```
```text
danwo dashboard for ~/code/shop
http://127.0.0.1:4310/
```
It finds the project from the nearest `danwo.config.ts`. It never writes to `.danwo`, so leave it
open while you run tests and reload a page to see new runs. `-p 0` picks any free port.
## The pages [#the-pages]
The sidebar holds every page. Collapse it to icons with `Cmd+B` (or `Ctrl+B`).
| Page | What it shows |
| -------- | ----------------------------------------------------------------------------------------- |
| Overview | pass rate, runs, failed tests and run time over 7, 30 or 90 days, and what's worth a look |
| Tests | every test with its latest result, pass rate and matrix verdict |
| Runs | every `danwo test` and `danwo matrix` run, filtered by status, kind and date |
| Matrix | each test's latest matrix verdict and the faults that broke it |
| Insights | flake rate, the slowest and costliest tests, the replay cache, lint findings |
| Impact | what each test touched: pages, providers, webhooks |
| Settings | the project folder, the config, and keyboard shortcuts |
## Overview [#overview]

The four cards compare the chosen window with the one before. Click a day's bar to see its runs.
## Tests [#tests]

Each row shows the matrix's verdict: **breaks under** a fault, **held under all**, or no matrix yet.
**Run selected** and **Run all** start `danwo test` from the page.
## Runs [#runs]

Filter by status, kind and date. A test run lists each test with its attempts; click one to open
its recording.
## A matrix run [#a-matrix-run]

The grid has a row per call or webhook, and a column per fault kind. Red cells broke your invariant.
Click one to see why, then **Watch this trial** to open its recording.
## Insights [#insights]

Sort the table by flake rate, median time or spend to find the tests worth fixing first.
## Every page has a link [#every-page-has-a-link]
Every page, filter and open recording has its own address. **Copy link** in the top bar copies it,
so you can send a teammate the exact trial you're looking at.
## Next [#next]
Watch a test, read its calls, copy a prompt to fix it.
Watch runs as they happen, and start them from the page.
# Database and cache (/docs/data-faults)
Put a fault proxy in front of your own Postgres and Redis, and let the matrix break the calls your tests make.
Providers aren't the only thing that fails. A COMMIT's reply gets lost, a transaction hits a
serialization failure, the database restarts, Redis runs out of memory. `danwo matrix` can break
your own Postgres and Redis calls too. Your app connects through a **fault proxy**, and the matrix
plans faults on the statements and commands your tests sent.
## Wire the proxies [#wire-the-proxies]
Start a proxy in front of each store, give your app the proxy's URL, and list the proxies under
`data`. Add a snapshot so every test starts from the same rows:
```ts title="danwo.config.ts"
import {
appEnvironment,
defineConfig,
postgresSnapshot,
postgresWorkerDatabase,
startPostgresProxy,
startRedisProxy,
stripeService,
} from "@danwo/sdk"
const REDIS_URL = process.env.REDIS_URL ?? ""
export default defineConfig({
testDir: "tests",
async environment({ workerIndex }) {
const url = await postgresWorkerDatabase(process.env.DATABASE_URL ?? "", workerIndex)
const postgres = await startPostgresProxy({ upstream: url })
const cache = await startRedisProxy({ upstream: REDIS_URL, service: "cache" })
const db = postgresSnapshot({ url })
const env = await appEnvironment({
app: {
command: "npm run dev",
url: "http://localhost:3000",
env: { DATABASE_URL: postgres.url(url), REDIS_URL: cache.url(REDIS_URL) },
},
services: [stripeService()],
})
return {
...env,
data: [postgres, cache],
async snapshot(slot) {
await env.snapshot?.(slot)
await db.snapshot(slot)
},
async restore(slot) {
await env.restore?.(slot)
await db.restore(slot)
},
async close() {
await env.close()
await Promise.all([postgres.close(), cache.close(), db.close()])
},
}
},
})
```
- `postgresWorkerDatabase` gives each worker its own copy of your database, so parallel tests
never share rows.
- `proxy.url(realUrl)` swaps in the proxy's host and port, and keeps the user, password and
database. Snapshots keep using the real URL.
- `service` names the proxy in fault labels. It defaults to `postgres` or `redis`.
## The faults [#the-faults]
Labels read ` on ()`:
| Fault | Example label | What your app sees |
| ---------------------------- | --------------------------------------------------- | --------------------------------------------------------- |
| `reset` | `reset on INSERT orders (postgres)` | the connection drops before the server sees the call |
| `reset-after` | `reset-after on COMMIT (postgres)` | the call runs, then the connection drops before the reply |
| `delay` | `delay 5000ms on UPDATE stock (postgres)` | the call arrives late, like a timeout |
| `serialization_failure` | `serialization_failure on UPDATE orders (postgres)` | SQLSTATE 40001 |
| `deadlock_detected` | `deadlock_detected on INSERT orders (postgres)` | SQLSTATE 40P01 |
| `down` | `down 3000ms on BEGIN (postgres)` | new connections are refused for 3 seconds |
| `too_many_connections` | `too_many_connections 3000ms on BEGIN (postgres)` | each new connection gets FATAL 53300 |
| `OOM`, `LOADING`, `READONLY` | `OOM on INCR rate:login:* (cache)` | a Redis error in place of the reply |
Faults are planned only on calls your test caused, never on the driver's housekeeping or on a
reset. Run only some with a filter:
```sh
npx @danwo/cli matrix --faults "COMMIT"
```
## What each call gets [#what-each-call-gets]
| Call | Faults |
| --------------------- | ------------------------------------------------------------------- |
| A Postgres write | reset, reset-after, delay, serialization_failure, deadlock_detected |
| A COMMIT | reset, reset-after, serialization_failure |
| A BEGIN | down, too_many_connections |
| A Redis write or EXEC | reset, reset-after, delay, OOM, READONLY |
| A Redis read | reset, LOADING |
Replace the list with `matrix.dataFaults: (call) => DataFault[]` in the config.
## What a correct app does [#what-a-correct-app-does]
| Fault | Handle it by |
| --------------------- | ------------------------------------------------------------------------------------------- |
| A lost COMMIT reply | giving the work a key and inserting with `ON CONFLICT (key) DO NOTHING`, so a retry is safe |
| 40001 or 40P01 | rolling back and running the whole transaction again, with backoff |
| Database down or full | retrying the connection for a few seconds, then saying so |
| Cache errors | choosing to fail open or closed. A rate limiter that fails open lets everyone through |
The lint rule `data/no-retry-on-serialization` flags a transaction given up after 40001 or 40P01.
## Limits [#limits]
- No TLS between your app and the store: Postgres with `sslmode=disable` or `prefer`, no
`rediss://`.
- Statements are classified from their SQL text. Unusual ones such as `CALL`, `DO` and `LOCK`
aren't faulted.
- MySQL isn't supported yet.
## Next [#next]
How snapshots give every test the same rows.
Risky usage danwo spots in every run.
# Debugging a failure (/docs/debugging)
Read a failed run's page from top to bottom, and know what each error code asks you to look at.
Every failed run keeps a [flight record](/docs/flight-records). Start from its **failure page**: the
error, the steps, the screen, and what happened behind it, in one place.
## Open the failure page [#open-the-failure-page]
```sh
npx @danwo/cli show --format md
```
It prints one page per failed test or matrix trial of the newest run. Pass a run id for an older
one.
```sh
npx @danwo/cli serve --open
```
Click the failed test. The video plays with the steps and provider calls moving along with it.
The `show_run` MCP tool with `format: "md"` returns the same page. See [Fix a
failure](/docs/fix-with-an-agent).
## Read it top to bottom [#read-it-top-to-bottom]
### The top: what failed, and where [#the-top-what-failed-and-where]
The test, the fault if a matrix trial, the error code, and the line the failure went through:
```text
fault: duplicate on webhook checkout.session.completed · failed · EXPECTATION_FAILED (test) · 2.7 s
**Error at tests/buy-credits.danwo.ts:19:**
invariant: expected 1000 to be 500 // Object.is equality
```
### The steps [#the-steps]
Each step with its status, its [cache](/docs/replay-cache) outcome, model calls, time and line:
```text
| 4 | getByRole("heading", name: "Thank you for your purchase") toBeVisible() | passed | … | tests/buy-credits.danwo.ts:15 |
| 5 | getByText(/^Credits: /) toHaveText("Credits: 500") | failed: EXPECTATION_FAILED | … | tests/buy-credits.danwo.ts:16 |
```
### The screen at failure [#the-screen-at-failure]
What the page showed, as an accessibility tree:
```text
- main:
- heading "Thank you for your purchase" [level=1]
- paragraph: "Credits: 1000"
```
### What was behind the page [#what-was-behind-the-page]
Console errors, failed or faulted provider calls, webhook deliveries, and each emulator's state at
the end. Here, the same webhook arrived twice and the app answered both:
```text
| topic | attempt | status | fault |
| checkout.session.completed | 1 | 200 | duplicate |
| checkout.session.completed | 1 | 200 | |
```
That's the bug: the app credited the account once per delivery.
## What the error code says to check [#what-the-error-code-says-to-check]
| Code | Look at |
| ------------------------------------------- | ----------------------------------------------------------------------- |
| `LOCATOR_NOT_FOUND`, `LOCATOR_AMBIGUOUS` | the screen at failure, and the nearest candidates it lists |
| `EXPECTATION_FAILED` | expected against received: is the app wrong, or the test? |
| `CLAIM_FAILED` | the judge's reason. The claim may be too strict. |
| `APP_ERROR` | your app's own 5xx or uncaught error: an app bug |
| `PROVIDER_ERROR` | the failed provider call, and whether your app recovered |
| `STALE_RECORDING`, `NOTHING_RECORDED` | run `danwo test` once to record, then the matrix |
| `AGENT_LOOPING`, `BUDGET_EXCEEDED` | a vague instruction or a changed page. Split the step, or use locators. |
| `MODEL_OUTPUT_INVALID`, `MODEL_UNAVAILABLE` | the model provider, not your app. Retry, or change model. |
| `MODEL_REFUSED` | the model declined the step. Reword it, or run it on another model. |
| `BROWSER_FAILED`, `ENVIRONMENT_FAILED` | the browser or the environment: app start-up, emulators, ports |
| `CONFIG_INVALID` | the config, or the call the message names |
`danwo spec --topic errors` lists every code with its exit code.
When a run fails before any test starts, `npx @danwo/cli doctor` checks the machine and the
config.
## Connection noise [#connection-noise]
A browser dropping a socket as a page closes (`ECONNRESET`, `EPIPE`, `socket hang up`) is expected,
so danwo doesn't print it. To see those lines while chasing a connection problem:
```sh
DANWO_DEBUG=proxy npx @danwo/cli test
```
Every other proxy error always prints.
## Next [#next]
Hand the failure page to your coding agent.
Everything a record keeps, and how to replay it.
# Email and 2FA codes (/docs/email-and-otp)
Read sign-in codes and magic links from the mail your app sent, type authenticator codes, and replay it all with no model.
Sign-up and sign-in often go through an inbox: a code to type, a magic link to click, a text
message. danwo reads them from the emulators your app sends through, so your test never polls a
real inbox.
## Tell danwo where mail lands [#tell-danwo-where-mail-lands]
A **mail source** tells danwo where your app's mail lands. `clerkService()` adds its own. For
the others, add the source to the environment's `mail`:
```ts title="danwo.config.ts"
import { appEnvironment, defineConfig, resendMail, resendService } from "@danwo/sdk"
export default defineConfig({
testDir: "tests",
async environment() {
const resend = resendService()
const env = await appEnvironment({
app: { command: "npm run dev", url: "http://localhost:3000" },
services: [resend],
})
return { ...env, mail: [...(env.mail ?? []), resendMail(resend.emulator)] }
},
})
```
Tests then get a `mail` fixture that reads every source.
| Source | What it reads |
| ----------------------- | ----------------------------------------------- |
| `resendMail(emulator)` | emails your app sent through Resend |
| `gmailMail(emulator)` | mail delivered to a Gmail mailbox |
| `outlookMail(emulator)` | mail delivered to an Outlook mailbox |
| `twilioSms(emulator)` | texts sent to phones; the address is the number |
| `clerkMail(emulator)` | Clerk's sign-in and sign-up codes |
## Type a code from an email [#type-a-code-from-an-email]
`mail.code(address)` waits for the newest email to that address and returns the code in it:
```ts title="tests/sign-in-code.danwo.ts"
import { test } from "@danwo/sdk"
test("signs in with an emailed code", async ({ app, screen, mail, expect }) => {
await app.open("/login")
await screen.getByLabel("Email").fill("ada@shop.test")
await screen.getByRole("button", { name: "Send code" }).click()
await screen.getByLabel("Code").fill(await mail.code("ada@shop.test"))
await expect(screen.getByRole("heading", { name: "Welcome" })).toBeVisible()
})
```
## Follow a magic link [#follow-a-magic-link]
`mail.link(address)` returns a link from the newest email. Pass `match` to pick one by its text:
```ts title="tests/magic-link.danwo.ts · the link"
await app.open(await mail.link("ada@shop.test", { match: "Sign in" }))
```
Or open the email itself in the browser, and click in it like a user: `await mail.open(address)`.
## Let the agent read the mail [#let-the-agent-read-the-mail]
The agent can read codes and links too. Its recording keeps **where** the value came from, never
the value, so a replay with a fresh address reads the new code:
```ts title="tests/sign-up.danwo.ts"
import { test, unique } from "@danwo/sdk"
test("signs up and confirms by email", async ({ app, agent }) => {
await app.open("/sign-up")
await agent.act("sign up as {email} and confirm with the emailed code", {
params: { email: unique("ada@shop.test") },
})
})
```
## Type an authenticator code [#type-an-authenticator-code]
For 2FA with an authenticator app, configure the base32 key your app showed at enrolment as a
`totp` secret:
```ts title="danwo.config.ts · the 2FA key"
secrets: { otpKey: { value: process.env.TOTP_KEY ?? "", purpose: "totp" } },
```
Filling it types the code for that moment. The key and the codes are redacted everywhere:
```ts title="tests/two-factor.danwo.ts · the code"
await screen.getByLabel("Authentication code").fill(secret("otpKey"))
```
`totp(key)` computes a code directly, when a test needs the number itself.
Codes are read from the emulators, so the same test replays on every run with no model call, even
though each run gets a new code.
## Next [#next]
Do the sign-in once per worker.
Keep passwords and 2FA keys out of every record.
# Seeding and state (/docs/emulator-state)
Start an emulator with the data your test needs, and read back what your app did at it.
An emulator starts empty. A **seed** gives it the data your test expects to find: a webhook
endpoint, a bucket, a user. After each test, danwo puts it back to that seed.
## Seed an emulator [#seed-an-emulator]
Pass `seed` to the service. Stripe's seed is a function, so it can use values known only once your
app runs, like its webhook URL:
```ts title="danwo.config.ts"
import { appEnvironment, defineConfig, s3Service, stripeService } from "@danwo/sdk"
const url = "http://localhost:3000"
export default defineConfig({
testDir: "tests",
environment: () =>
appEnvironment({
app: { command: "npm run dev", url },
services: [
stripeService({
seed: () => ({
webhookEndpoints: [
{
url: `${url}/api/webhooks/stripe`,
events: ["checkout.session.completed"],
secret: "whsec_test",
},
],
}),
}),
s3Service({ seed: { buckets: [{ name: "uploads" }] } }),
],
}),
})
```
Each provider page lists what its seed takes. See [Providers](/docs/providers).
## Read what your app did [#read-what-your-app-did]
Each emulator has a helper that reads its state in plain terms. Call it with the emulator's
fixture:
```ts title="tests/upload.danwo.ts"
import { type S3Fixtures, s3Objects, test as base } from "@danwo/sdk"
const test = base.extend()
test("uploads a receipt", async ({ app, screen, s3, expect }) => {
await app.open("/receipts")
await screen.getByLabel("Receipt").setInputFiles("fixtures/receipt.pdf")
await screen.getByRole("button", { name: "Upload" }).click()
expect(s3Objects(s3).list("uploads", "receipts/")).toHaveLength(1)
})
```
A few of the helpers:
| Fixture | Helper | Reads |
| -------- | -------------------------------- | -------------------------------------- |
| `resend` | `resendInbox(resend)` | emails your app sent |
| `s3` | `s3Objects(s3)` | buckets and objects |
| `slack` | `slackWorkspace(slack)` | messages your app posted |
| `twilio` | `twilioInbox(twilio)` | texts that reached phones |
| `github` | `githubRepo(github, "org/repo")` | issues, comments and check runs |
| `llm` | `llmCalls(llm)` | your app's calls to OpenAI, Anthropic… |
The [emulator helpers reference](/docs/reference/emulator-helpers) lists them all.
## Ask the API directly [#ask-the-api-directly]
Every emulator also takes requests through its fixture, the way your app would call the real API.
Use it when there's no helper for what you need:
```ts title="tests/charges.danwo.ts"
import { type StripeFixtures, test as base } from "@danwo/sdk"
const test = base.extend()
test("charges once", async ({ app, screen, stripe, expect }) => {
await app.open("/")
await screen.getByRole("button", { name: "Buy" }).click()
const res = await stripe.fetch(
new Request("https://api.stripe.com/v1/payment_intents", {
headers: { authorization: "Bearer sk_test_any" },
}),
)
const { data } = await res.json()
expect(data).toHaveLength(1)
})
```
## Act as someone outside your app [#act-as-someone-outside-your-app]
Some helpers act as a third party, so you can test what happens when something arrives from
outside:
```ts title="tests/inbound-sms.danwo.ts · a text arrives"
await twilioPhone(twilio).send("+15555550100", "+15555550199", "STOP")
```
That's a phone texting your app's number. Other helpers post a Slack message as a user, pay into a
virtual account, or revoke a sign-in.
State never leaks between tests. Every test starts from the seed, and so does every retry. See
[Isolation](/docs/isolation).
## Next [#next]
How every test starts from the seed, and when to share state.
Compare your app's state with the provider's after every trial.
# Emulators (/docs/emulators)
Local stand-ins for the services your app calls, answering on the real hostnames behind danwo's proxy, with no change to your code.
An **emulator** is a local copy of a service your app depends on, such as Stripe, Resend or Slack.
It answers the same API, sends the same webhooks and returns the same errors. Your app keeps
calling `api.stripe.com`; danwo's proxy makes sure the emulator answers.
## Why emulate instead of using a sandbox [#why-emulate-instead-of-using-a-sandbox]
A real test account is slow, shared and flaky, and it can't fail on demand. An emulator starts in
milliseconds, holds only what your test put there, and resets between tests. It can also answer a
500, drop a connection or send a webhook twice, which is how the
[fault matrix](/docs/fault-matrix) tests your app's error handling.
## Add one to your config [#add-one-to-your-config]
List the emulators you need under `services`. danwo starts them behind one proxy, then starts your
app through it:
```ts title="danwo.config.ts"
import { appEnvironment, defineConfig, resendService, stripeService } from "@danwo/sdk"
export default defineConfig({
testDir: "tests",
environment: () =>
appEnvironment({
app: { command: "npm run dev", url: "http://localhost:3000" },
services: [stripeService(), resendService()],
}),
})
```
Every provider page lists its service: see [Providers](/docs/providers).
## How the proxy reaches your app [#how-the-proxy-reaches-your-app]
danwo's **egress proxy** sits between your app and the internet. It starts your app with a few
environment variables:
| Variable | What it does |
| --------------------- | --------------------------------------------------------------- |
| `HTTPS_PROXY` | sends the app's outgoing calls through the proxy |
| `NODE_OPTIONS` | loads danwo's preload, so Node's own HTTP clients use the proxy |
| `NODE_EXTRA_CA_CERTS` | trusts the proxy's certificate for this run only |
The proxy answers emulated hosts itself, records every call, and blocks or passes through the rest.
Your app's code and keys don't change.
If you set `APP_URL` to test an app that's already running, that app wasn't started through the
proxy. Its calls go to the **real** services. Use it only with test keys.
## What an emulator gives your test [#what-an-emulator-gives-your-test]
Each emulator adds a **fixture** to your tests, named after it: `stripe`, `resend`, `slack`.
Through it, a test or an invariant reads what your app did:
```ts title="tests/welcome-email.danwo.ts"
import { type ResendFixtures, resendInbox, test as base } from "@danwo/sdk"
const test = base.extend()
test("sends a welcome email", async ({ app, screen, resend, expect }) => {
await app.open("/sign-up")
await screen.getByLabel("Email").fill("ada@shop.test")
await screen.getByRole("button", { name: "Create account" }).click()
expect(resendInbox(resend).latest("ada@shop.test")?.subject).toBe("Welcome to the shop")
})
```
[Seeding and state](/docs/emulator-state) shows how to start an emulator with data, and every
way to read it back.
## Is it accurate? [#is-it-accurate]
Every emulator is checked against the real API with **fidelity** suites: the same calls go to both,
and danwo compares the answers field by field. See [Fidelity](/docs/fidelity).
## Next [#next]
Start emulators with data, and read what your app did.
Every service danwo emulates.
# Fault kinds (/docs/fault-kinds)
Every kind of fault the matrix can inject, what it simulates, and what it catches.
Every trial injects one fault. A fault is named by a **label**: what happens, then where it
happens. For example, `status 500 on POST api.stripe.com/v1/checkout/sessions`.
## Provider calls [#provider-calls]
Faults on a call your app makes to Stripe, Resend, Slack or another provider:
| Fault | What happens | What it catches |
| -------------------------- | ------------------------------------------------------ | -------------------------------------------- |
| `status 500`, `status 503` | The provider fails. | Errors shown as success, missing retries. |
| `status 429` | The provider rate-limits the call. | Retries that hammer the API, or none at all. |
| `reset` | The connection drops before the provider answers. | Calls with no timeout or error handling. |
| `reset-after` | The provider **did** the work, but the answer is lost. | Retries that charge or send twice. |
| `delay ms` | The answer is slow. | Timeouts that are too short, or missing. |
Each provider's error bodies match what its own SDK expects, so your app's error handling runs
as it would in production.
## Model APIs [#model-apis]
Calls to OpenAI, Anthropic and the other model APIs can also get answers that go wrong. These
check what your app does when the model answers badly.
| Fault | What the model sends |
| --------------------- | ------------------------------------------ |
| `refusal` | a refusal instead of an answer |
| `empty-completion` | an answer with no text |
| `truncated` | an answer cut off at the token limit |
| `content-filter` | an answer stopped by the provider's filter |
| `malformed-tool-call` | a tool call whose arguments don't parse |
| `stream-cut` | a stream that stops halfway |
## Webhooks [#webhooks]
Faults on a webhook a provider sends your app:
| Fault | What happens | What it catches |
| ---------------- | -------------------------------------------- | ----------------------------------------------- |
| `duplicate` | The same webhook arrives twice. | Work done twice, such as credits granted twice. |
| `delay 600000ms` | It arrives ten minutes late. | Pages that assume the webhook came first. |
| `forged` | A webhook arrives with a bad signature. | Handlers that skip the signature check. |
| `stale 3600s` | A correctly signed webhook from an hour ago. | Handlers that accept replayed requests. |
## Races [#races]
Faults on timing, planned only where they can happen:
| Fault | What happens |
| ---------------------------- | ------------------------------------------------------------------------------ |
| `double-submit on POST /buy` | A form or request is sent twice at once. |
| `webhooks before redirect` | The provider's webhooks arrive before the browser returns to your app. |
| `reorder webhooks a, b` | Two webhooks arrive the other way round. |
| `slow-then-retry` | A payment call is slow; your app retries while the first one still succeeds. |
| `2 concurrent users` | The whole test runs as two users at once. Ask for it with `{ concurrent: 2 }`. |
## Database and cache [#database-and-cache]
With danwo's data proxies in front of your app's Postgres and Redis, the matrix also faults the
statements your test caused:
| Fault | What happens |
| ----------------------------------- | ------------------------------------------------------ |
| `reset-after on COMMIT` | The database committed, but your app never heard back. |
| `serialization_failure`, `deadlock` | Postgres asks your app to retry the transaction. |
| `down 3000ms` | The database is unreachable for three seconds. |
| `OOM`, `READONLY` | Redis refuses a write. |
See [Database and cache](/docs/data-faults) to set this up.
## Sockets and background jobs [#sockets-and-background-jobs]
| Fault | What happens |
| ------------------------------------------- | --------------------------------------- |
| `upgrade refused on WS …` | A WebSocket can't connect. |
| `connection dropped on WS …` | A connected WebSocket drops. |
| `status 500 on POST inngest.danwo.test/e/…` | A background job's event can't be sent. |
## Time [#time]
Faults don't move the clock. To test a trial that ends tomorrow or a token that expires, jump
time forward inside the test. See [Time travel](/docs/time-travel).
## Next [#next]
What these faults usually find, and the fixes.
Every provider's fault list, generated from the code.
# Overview (/docs/fault-matrix)
Replay one test under every fault it could meet, and check your app coped with each one.
The fault matrix replays a test once for each thing that could go wrong while it runs: a
declined card, a 500 from Stripe, a webhook that arrives twice, a button clicked twice. After
each replay, it checks a rule you wrote. You learn which failures your app survives before your
users find the ones it doesn't.
## Why you need it [#why-you-need-it]
A UI test checks the happy path. Real providers fail in small, ordinary ways. A webhook comes
twice. A response is lost after the payment went through. A user double-clicks **Pay**.
Each of those can leave your app wrong while the screen looks fine. The matrix makes each one
happen, on purpose, one at a time.
## How it works [#how-it-works]
1. **A clean run.** danwo runs your test once with nothing broken, and records every call,
webhook and database statement it caused.
2. **A plan.** For each of those, it lists the faults that could happen to it. Each fault becomes
a **trial**: one replay of the test with that one fault.
3. **The trials.** Each trial starts from the same clean state, replays your test's steps, and
injects its fault at the right moment.
4. **The verdict.** After each trial, danwo runs your test's **invariant**: a check that must
hold however the run went.
## A first matrix [#a-first-matrix]
Give a test an invariant. This one says the credits your app granted equal the credits Stripe
was paid for:
```ts title="tests/buy-credits.danwo.ts · the invariant"
}).invariant(async ({ app, stripe, expect }) => {
expect(await creditsGranted(app)).toBe(await creditsPaid(stripe))
})
```
Then run the matrix on that file:
```sh
npx @danwo/cli matrix tests/buy-credits.danwo.ts
```
```text
clean run passed; 18 faults planned
PASS status 500 on POST api.stripe.com/v1/checkout/sessions
FAIL duplicate on webhook checkout.session.completed
invariant: expected 1000 to be 500
faults: 17 passed, 1 failed
```
The failure is a real bug: a webhook delivered twice granted the credits twice.
## What each result means [#what-each-result-means]
| Result | Meaning |
| ------------ | ------------------------------------------------------------------------------------------------------------ |
| `PASS` | The invariant held under the fault. The test itself may have stopped early; that's fine. |
| `FAIL` | The invariant broke. The record shows where the test stopped and why. |
| `INVALID` | The fault never happened, because the app took another path. |
| `NOT JUDGED` | The trial was blocked, the test stopped before the fault could happen, or something outside your app failed. |
## Next [#next]
What to compare, and how to read each side.
Run every fault, a few, or one; locally or in CI.
The grid, a failed trial, and its recording.
Every kind of fault the matrix can inject.
# Fidelity test accounts (/docs/fidelity-accounts)
Sign a test account in once, so the Gmail, Calendar, Drive and Microsoft Graph fidelity suites can run against the real APIs.
The Google and Microsoft suites act as a person's account: they send mail, make events and upload
files. To run them live, you sign a test account in once with `danwo fidelity login`, and danwo
keeps its refresh token in `.env.fidelity`.
Offline, nothing is needed. The emulator plays the account, and the goldens come from what Google
and Microsoft publish: the Discovery documents for Gmail, Calendar and Drive, and Microsoft Graph's
OpenAPI description.
```sh
npx @danwo/cli fidelity login google
npx @danwo/cli fidelity google
```
## What the login does [#what-the-login-does]
It listens for the redirect on this machine, opens the provider's consent page, and exchanges the
code with PKCE. It shows the scopes it asks for before the browser opens:
```text
Signing a Google test account in for the fidelity suites. It is asked for:
openid
email
https://www.googleapis.com/auth/gmail.modify
https://www.googleapis.com/auth/calendar.events
https://www.googleapis.com/auth/calendar.calendarlist.readonly
https://www.googleapis.com/auth/drive.file
The consent page sends the browser back to http://127.0.0.1:8765/callback; the client must register it.
Sign in with a test account, not your own. If the browser does not open, go to:
https://accounts.google.com/o/oauth2/v2/auth?...
Saved DANWO_FIDELITY_GOOGLE_REFRESH_TOKEN in .env.fidelity for tester@gmail.com.
Granted: openid email https://www.googleapis.com/auth/gmail.modify ...
Run the live suite with: danwo fidelity google
```
The token itself is never printed. Only its line in `.env.fidelity` changes; the other lines,
comments and the file's `600` mode stay as they were.
| Option | Does | Default |
| ---------------------- | ------------------------------------------- | -------------------------------- |
| `--port ` | the port of the default redirect URI | `8765` |
| `--redirect-uri ` | a redirect URI the client already registers | `http://127.0.0.1:8765/callback` |
## Make a Google client [#make-a-google-client]
1. In the Google Cloud Console, pick a project and enable the Gmail, Calendar and Drive APIs.
2. Under **Google Auth Platform → Audience**, keep the app in **Testing** and add your test
account as a test user.
3. Under **Clients**, create a **Web application** client and register the redirect URI.
4. Put the client in `.env.fidelity`.
```sh title=".env.fidelity"
DANWO_FIDELITY_GOOGLE_CLIENT_ID=....apps.googleusercontent.com
DANWO_FIDELITY_GOOGLE_CLIENT_SECRET=...
```
Google expires the refresh tokens of an app in Testing after seven days. When a live run says the
token was refused, run the login again.
## Make a Microsoft client [#make-a-microsoft-client]
1. In the Azure portal, open **Microsoft Entra ID → App registrations** and register a new app.
For a personal test account, allow personal Microsoft accounts.
2. Add a **Web** platform redirect URI, the one the login uses.
3. Under **Certificates & secrets**, create a client secret and copy its value.
4. Add the delegated Graph permissions for mail, calendars and files.
```sh title=".env.fidelity"
DANWO_FIDELITY_MICROSOFT_CLIENT_ID=...
DANWO_FIDELITY_MICROSOFT_CLIENT_SECRET=...
DANWO_FIDELITY_MICROSOFT_TENANT_ID=common
```
## Reuse your app's OAuth client [#reuse-your-apps-oauth-client]
If your app already signs people in to Google or Microsoft, its client works too. Give danwo one
of the redirect URIs it registers, and danwo listens on that URI's host, port and path.
```sh title=".env.fidelity"
DANWO_FIDELITY_GOOGLE_REDIRECT_URI=http://localhost:3050/settings/integrations/google-verify
DANWO_FIDELITY_MICROSOFT_REDIRECT_URI=http://localhost:3050/settings/integrations/microsoft-verify
```
Stop your app's dev server first, since the login needs its port. If the port is busy it says so:
```text
danwo fidelity login: port 3050 on localhost is in use: stop the app on :3050 first (its dev server, most likely), then run the sign-in again
```
The URI must use `http` on `localhost` or `127.0.0.1`. The client must also allow the scopes the
login asks for.
## When it fails [#when-it-fails]
| Message | What to do |
| ------------------------------------------- | ----------------------------------------------------------------- |
| set the client id and secret | add the client to `.env.fidelity` |
| the token endpoint refused the redirect URI | register exactly the URI it names, or pass the registered one |
| no answer from the consent page | the browser never came back; check the redirect URI is registered |
| the sign-in was cancelled | you chose Cancel; run it again |
| gave no refresh token | remove the app's access from the account, then sign in again |
## Live runs stay in their lane [#live-runs-stay-in-their-lane]
- Everything a run makes is tagged `danwo-fidelity-`, and removed after each scenario.
- Mail goes only to the account's own address.
- Untagged mail, events and files are never touched. `--sweep` lists tagged leftovers.
A live run refuses an account that doesn't look personal, such as a work address. To run as one on
purpose, name it in `DANWO_FIDELITY_GOOGLE_ACCOUNT` or `DANWO_FIDELITY_MICROSOFT_ACCOUNT`.
## Next [#next]
How the suites compare the emulator with the real provider.
What a live run makes, and how it's removed.
# Cleaning up sandboxes (/docs/fidelity-cleanup)
How a live fidelity run tags what it makes, undoes it after each scenario, and sweeps up anything left behind.
A live fidelity run creates real objects in your sandboxes: customers, users, documents. danwo
tags each one and removes it when the scenario ends, so your sandbox stays as it was.
## Tagged [#tagged]
Everything a run makes carries `danwo-fidelity-`: in Stripe customer emails and product
names, Clerk addresses and organization names, Novu subscriber ids, Tiptap document names, Pusher
channels, Resend subjects and the payment references.
## Undone after each scenario [#undone-after-each-scenario]
Each create registers its undo in the run's **cleanup ledger**. After every scenario, passed,
failed or thrown, the ledger undoes in reverse order and retries each undo once. An answer that the
object is already gone counts as undone.
Ctrl-C undoes what the open scenarios made before danwo exits. Anything that couldn't be undone is
listed under **left behind** by kind and id, and fails the run.
`--keep` leaves everything in place so you can look at it, and lists it as kept.
## Sweep leftovers [#sweep-leftovers]
```sh
npx @danwo/cli fidelity --sweep stripe # list what earlier runs left
npx @danwo/cli fidelity --sweep stripe --yes # remove it
```
A sweep only removes what carries the tag. Look-alikes without one are listed for you to judge and
never touched.
## By provider [#by-provider]
| Provider | Undone after each scenario | Stays behind | Sweep |
| ----------- | ------------------------------------------------------------------------------ | ------------------------------------------------ | ------------------------------ |
| Stripe | customers deleted, subscriptions canceled, products archived, sessions expired | payment intents and refunds stay in test history | tagged customers and products |
| Clerk | users and organizations deleted, invitations and sessions revoked | nothing | tagged users and organizations |
| Novu | subscribers deleted | nothing | tagged subscribers |
| Tiptap | documents deleted | nothing | tagged documents |
| Paystack | nothing to undo | one abandoned test transaction per run | none |
| Flutterwave | nothing to undo | one tagged test payment link per run | none |
| Bachs | nothing to undo | one sandbox customer per run | none |
| Resend | nothing to undo | sent emails, all to `delivered@resend.dev` | none |
| Twilio | a full account's one real send, deleted once it settles | a trial account's send | none |
| S3 (Tigris) | every object under the run's prefix deleted, uploads aborted | nothing | none |
| Others | they only read | nothing | none |
## Next [#next]
Run the checks.
Every option in `danwo.config.ts`.
# Fidelity (/docs/fidelity)
Check that each emulator answers like the real provider, against the provider's own sandbox.
An emulator is only useful if it answers like the real thing. `danwo fidelity` runs the same
scenarios against danwo's emulator and the provider's sandbox, and compares the answers.
## What it compares [#what-it-compares]
The **shape** of each answer: status codes, error codes, and the JSON's keys, types, nesting and
fixed values. Ids, times, amounts and secrets never take part, so two runs a day apart still match.
## Run it [#run-it]
Without keys, it checks each emulator against the answers recorded from the sandbox:
```sh
npx @danwo/cli fidelity stripe resend --offline
```
```text
stripe (offline): 11 of 11 scenarios match
✓ customers: create, retrieve, update, delete
✓ customers: list pages, newest first
✓ errors: missing object, missing parameter, bad key (1 known)
✓ idempotency: replay and conflicting reuse
✓ products and prices: one-off and recurring
✓ payment intent: create, confirm, expand, refund
✓ checkout session: create, retrieve, expire (9 known)
✓ events: a created customer appears as customer.created
✓ setup intent: create and confirm with a test card
✓ subscriptions: trial, active, cancel at period end, cancel
✓ invoices: a subscription's first invoice, paid, and its list
resend (offline): 5 of 5 scenarios match
✓ emails: send and read back (1 known)
✓ emails: batch send
✓ emails: idempotent send
✓ errors: missing field, missing key
✓ domains: list
```
A difference prints with its scenario and step, as a missing field, an extra field, a type, status
or error-code mismatch, an ordering difference or a missing step. **known** counts differences
accepted on purpose, each with its reason in `fidelity/known-differences.json`.
## Against the sandbox [#against-the-sandbox]
With sandbox keys set, the same command calls the provider too:
| Command | Does |
| --------------------------------------- | ------------------------------------------------------ |
| `danwo fidelity` | every provider; live where keys are set |
| `danwo fidelity stripe resend` | some providers |
| `danwo fidelity stripe --update-golden` | records the sandbox's answers as the new golden |
| `danwo fidelity --from-openapi` | goldens from each provider's published API description |
| `danwo fidelity --offline` | never calls a sandbox, even with keys set |
## Keys [#keys]
Keys come from the environment, or from `.env.fidelity` and then `.env` at the repository root.
Git ignores both.
```sh title=".env.fidelity"
DANWO_FIDELITY_STRIPE_KEY=sk_test_...
DANWO_FIDELITY_RESEND_KEY=re_...
```
Only test and sandbox keys. A key that looks live, such as `sk_live_`, stops the run with
`CONFIG_INVALID`. Keys are never printed or written, and goldens hold structure only.
The model APIs (AI Gateway, Gemini, Perplexity, Bedrock) have no sandbox, so their keys are real.
With one set, a plain `danwo fidelity` runs that suite live, using the cheapest model and 20 output
tokens. Use `--offline` to stay off the network.
## Providers [#providers]
| Provider | Key |
| ------------------------------ | --------------------------------------------------------------------------------------------- |
| Stripe | `DANWO_FIDELITY_STRIPE_KEY` (`sk_test_` or `rk_test_`) |
| Resend | `DANWO_FIDELITY_RESEND_KEY`; sends only to `delivered@resend.dev` |
| S3 (Tigris) | `DANWO_FIDELITY_TIGRIS_ACCESS_KEY_ID`, `_SECRET_ACCESS_KEY`, `_BUCKET` (an empty test bucket) |
| Twilio | `DANWO_FIDELITY_TWILIO_ACCOUNT_SID` plus an API key or auth token |
| Paystack | `DANWO_FIDELITY_PAYSTACK_KEY` (`sk_test_`) |
| Flutterwave | `DANWO_FIDELITY_FLUTTERWAVE_KEY` (`FLWSECK_TEST-`) |
| Bachs | `DANWO_FIDELITY_BACHS_KEY` (`sk_sandbox_`) |
| Clerk | `DANWO_FIDELITY_CLERK_SECRET_KEY` and `_PUBLISHABLE_KEY` of a development instance |
| Pusher | `DANWO_FIDELITY_PUSHER_APP_ID`, `_KEY`, `_SECRET`, `_CLUSTER` (a test app) |
| Tiptap | `DANWO_FIDELITY_TIPTAP_APP_ID`, `_API_SECRET` |
| Slack, GitHub | `DANWO_FIDELITY_PUBLIC=1` (public calls only) |
| Google, Microsoft | a signed-in test account, from `danwo fidelity login` |
| Composio, Novu, Nylas, Nango | `DANWO_FIDELITY_PUBLIC=1`, plus an optional key |
| Unipile | `DANWO_FIDELITY_UNIPILE_DSN`, `_KEY` |
| AI Gateway, Gemini, Perplexity | `DANWO_FIDELITY_AI_GATEWAY_KEY`, `_GEMINI_KEY`, `_PERPLEXITY_KEY` |
| Bedrock | `DANWO_FIDELITY_BEDROCK_API_KEY` |
`npx @danwo/cli guide fidelity` lists each suite's scenarios and the optional keys.
The offline check runs in danwo's own test suite, so an emulator change that breaks fidelity fails
before it ships.
## Next [#next]
Sign a test account in for the Gmail, Calendar, Drive and Graph suites.
What a live run makes, and how it's removed.
Every emulator danwo runs.
# Fix a failure (/docs/fix-with-an-agent)
Hand a failing test to your coding agent with its failure page, and get back a fix it has checked with danwo.
When a test fails, danwo writes a **failure page**: the error, the step it stopped at, the screen,
and every provider call and webhook behind it. Give that page to your coding agent and it has
everything it needs to find the cause, fix it, and prove the fix.
## Start from the failure [#start-from-the-failure]
You can start from three places. Each gives the agent the same page.
Open the failed test in `danwo serve` and click **Copy prompt to fix**. You get the failure page,
with two lines on top asking the agent to find the cause and run the test again:
```text
The danwo test "tests/buy-credits.danwo.ts › buys 500 credits through Stripe Checkout"
failed. Its failure page follows: the error, the step it stopped at, the provider calls
and webhooks behind it, and the faults injected.
Find the cause. Change the app when the record shows a bug, and the test only when the
app's behaviour changed on purpose. Then run the test again with `danwo test`.
```
Paste it into your agent.
Print the newest run's failure pages as Markdown:
```sh
npx @danwo/cli show --format md
```
Copy the page for the failing test into your agent, or let the agent read it with the `show_run`
MCP tool.
danwo's comment on the pull request lists each failure with its error, failing step and record.
Paste a failure's section into your agent. For the whole page, download the run's records from
the CI artifact into `.danwo/runs/`, and the agent reads them with `show_run`.
## What the agent reads [#what-the-agent-reads]
Here is the top of a real failure page, from a matrix trial that sent Stripe's webhook twice:
```text
# ✗ tests/buy-credits.danwo.ts › buys 500 credits through Stripe Checkout
fault: duplicate on webhook checkout.session.completed · failed · EXPECTATION_FAILED (test) · 2.7 s
**Error at tests/buy-credits.danwo.ts:19:**
invariant: expected 1000 to be 500 // Object.is equality
## Webhook deliveries
| topic | attempt | status | fault |
| checkout.session.completed | 1 | 200 | duplicate |
| checkout.session.completed | 1 | 200 | |
```
The page shows the cause plainly: the same webhook arrived twice, and the app granted credits
both times.
## The agent fixes the cause [#the-agent-fixes-the-cause]
A good agent changes the app, not the test, when the record shows a bug. Here it makes the webhook
handler skip an event it has already seen:
```ts title="src/webhooks.ts · the fix"
if (await seen(event.id)) return new Response(null, { status: 200 })
await remember(event.id)
await grantCredits(session.metadata.user, 500)
```
## The agent proves the fix [#the-agent-proves-the-fix]
Then it runs what failed, with danwo's MCP tools or the CLI:
```sh
npx @danwo/cli matrix tests/buy-credits.danwo.ts --faults duplicate
```
```text
clean run passed; 1 faults planned from the 18 possible, narrowed to /duplicate/
PASS clean run
PASS duplicate on webhook checkout.session.completed
faults: 1 passed
```
Last, it runs the whole matrix and the test suite again, so the fix didn't break another path.
Ask for the proof explicitly: "fix it, then show me the matrix passing". The agent then reports
the danwo output, not just its own summary.
## When the test is the problem [#when-the-test-is-the-problem]
Sometimes the app changed on purpose: a button was renamed, a step moved. Then the agent updates
the test. It checks the new locator with `check_locator` before writing it, so the fixed test
matches exactly one element.
## Next [#next]
Read a failure page yourself, part by part.
The bugs the matrix finds most, and their fixes.
# Flight records (/docs/flight-records)
Every run keeps a record of what happened, on the screen and behind it, so you can replay a test without running it again.
A **flight record** is what danwo keeps of one test run: a video, every step, every call your app
made to a provider, every webhook, the database statements, and what each emulator held at the
end. You can replay it in the dashboard, read it as a failure page, or hand it to a coding agent.
## Where records live [#where-records-live]
Every `danwo test` and `danwo matrix` run writes a folder under `.danwo/runs/`. Each test, and each
matrix trial, gets a folder of its own:
```text
.danwo/runs/20261020-103017-e625/
report.html
tests-buy-credits-buys-500-credits-through-stripe-checkout/
clean/
record.json
timeline.jsonl
screenshots/
video/
duplicate-on-webhook-checkout-session-completed/
…
```
danwo keeps the latest 50 runs (`record.keepRuns`) and deletes older ones; see [what each run costs on disk](/docs/recordings).
## What a record holds [#what-a-record-holds]
| Part | What it shows |
| ---------------- | --------------------------------------------------------------------- |
| Steps | each action and check, its time, its replay outcome and model calls |
| Video | the screen, with every step, call and fault marked on its timeline |
| Screenshots | the screen after each step, and at a failure |
| Provider calls | every request through the proxy, with redacted headers and bodies |
| Webhooks | every delivery the emulators sent your app, and its answer |
| Database calls | the statements and commands, when your stores go through a data proxy |
| Emulator state | what each emulated service held when the test ended |
| Console | what the page logged, errors first |
| Integration lint | risky provider usage danwo spotted in the calls |
## Replay a record [#replay-a-record]
Open the dashboard and click a test:
```sh
npx @danwo/cli serve --open
```
The recording opens with the steps on the left and tabs on the right: **Replay**, **Console**,
**Network**, **Providers**, **Emulator state** and **Effects**. In Replay, each step has a **Go to**
button that jumps the video there, and a playhead moves along the provider calls as it plays.
A link to the exact moment works: `?t=12.5` opens the video 12.5 seconds in, and `#step-3` where
step 3 starts. Copy one into a bug report.
## Read it as a failure page [#read-it-as-a-failure-page]
For a failed run, the terminal prints a page per failure:
```sh
npx @danwo/cli show --format md
```
It's the same page the dashboard's **Copy prompt to fix** copies. [Debugging a
failure](/docs/debugging) walks through it part by part.
## Choose what to keep [#choose-what-to-keep]
By default danwo keeps every run, with video. Change it in the config, or for one run:
| Option | Values | Default |
| -------- | -------------------------------- | -------- |
| `record` | `always`, `on-failure`, `off` | `always` |
| `video` | `on`, `retain-on-failure`, `off` | `on` |
```sh
npx @danwo/cli test --record on-failure --video retain-on-failure
```
## Secrets are redacted [#secrets-are-redacted]
Records never hold a configured secret. Every value you list under `secrets` is removed from
calls, logs and pages, in every encoding, as are provider keys and tokens. Screenshots mask fields
that hold a secret.
Video shows the screen as it was. A field holding a secret shows as dots, but a secret your app
prints as text is on screen. For suites that do that, set `video: "off"`.
## Next [#next]
Read a failure page, top to bottom.
Every page and tab of `danwo serve`.
# Guides for agents (/docs/for-coding-agents)
danwo's guides for coding agents: the same knowledge as these docs, written densely, as raw markdown.
These docs are written for people. Coding agents get the same knowledge from danwo's **guides**:
short, dense markdown files with every option and rule, meant to be read in full by a model.
You don't need to read them yourself. They're listed here so you can see what your agent knows.
## How an agent gets them [#how-an-agent-gets-them]
| Way | What it gives the agent |
| --- | --- |
| The skill | `danwo init` installs danwo's skill, which tells your agent to read the guide for a task first. |
| `danwo guide ` | Prints one guide in the terminal. `danwo guide` lists them. |
| The MCP server | Serves the guides as resources, next to its tools. |
| [`/llms.txt`](/llms.txt) | An index of these docs and the guides. |
| [`/llms-full.txt`](/llms-full.txt) | Every docs page and every guide in one file. |
| Each docs page as `.md` | Add `.md` to a docs address for its markdown. |
See [Coding agents](/docs/coding-agents) to set up your agent.
## The guides [#the-guides]
Each link opens the guide's raw markdown.
| Guide | Topic |
| --- | --- |
| [CI, the GitHub check and the pull request comment](/guides/ci.md) | `ci` |
| [The dashboard](/guides/dashboard.md) | `dashboard` |
| [Debugging a failure](/guides/debugging.md) | `debugging` |
| [Email codes, magic links and 2FA](/guides/email-and-otp.md) | `email-and-otp` |
| [Checking the emulators against the real providers](/guides/fidelity.md) | `fidelity` |
| [Writing a first test](/guides/first-test.md) | `first-test` |
| [Faults in the app's own database and cache](/guides/infrastructure.md) | `infrastructure` |
| [Integration lint](/guides/lint.md) | `lint` |
| [Testing an app that calls a model](/guides/llm.md) | `llm` |
| [Locators or the agent](/guides/locators-vs-agent.md) | `locators-vs-agent` |
| [Invariants and the fault matrix](/guides/matrix.md) | `matrix` |
| [Mobile apps on Android and iOS](/guides/mobile.md) | `mobile` |
| [Using danwo from Playwright Test](/guides/playwright.md) | `playwright` |
| [Emulating a provider](/guides/providers.md) | `providers` |
| [Secrets](/guides/secrets.md) | `secrets` |
| [Saved sessions](/guides/sessions.md) | `sessions` |
| [Time travel](/guides/time.md) | `time` |
# Frontend and backend (/docs/frontend-and-backend)
A frontend and an API in separate apps, both started by danwo behind one proxy.
Your frontend and your API are separate apps: a React or Vue frontend and a Node API, perhaps
with a background worker. danwo starts all of them, in order, behind one proxy. You get the same
coverage as a [full-stack app](/docs/full-stack-local).
## Set it up [#set-it-up]
List each app with its start command and address. `dependsOn` says what must be up first, and
`env` hands one app another's address:
```ts title="danwo.config.ts"
import { clerkService, defineConfig, stackEnvironment, stripeService } from "@danwo/sdk"
export default defineConfig({
testDir: "tests",
environment: () =>
stackEnvironment({
baseUrl: "web",
services: [stripeService(), clerkService()],
apps: [
{
name: "api",
command: "npm run dev --prefix api",
url: "http://127.0.0.1:4000",
ready: { path: "/health" },
},
{
name: "web",
command: "npm run dev --prefix web",
url: "http://127.0.0.1:3000",
dependsOn: ["api"],
env: (urls) => ({ VITE_API_URL: urls.api ?? "" }),
},
],
}),
})
```
danwo starts the API and waits for `/health` to answer. Then it starts the web app, with
`VITE_API_URL` pointing at the API.
`baseUrl` names the app the browser opens. Without it, tests open the first app listed.
## What you get [#what-you-get]
- **Both sides of every provider emulated.** Give both apps the Clerk emulator's keys, and the
browser signs in with the emulator while the API checks the token against it. The two agree.
- **Faults on the frontend's calls to the API**, as well as on the API's calls to providers and
its database.
- **Background work.** Add [`inngestService()`](/docs/real-stacks) and `settle()` waits for jobs
the API started.
## Pitfalls [#pitfalls]
- **Ports.** Each app needs a fixed address, so run one worker per machine, or give each worker
its own ports.
- **Start-up time.** A large API can take a minute to boot. Raise `timeoutMs` on that app.
- **Calls that skip the proxy.** A Node WebSocket client that ignores proxy settings (the `ws`
package, for example) needs a proxy agent. Browser sockets always go through.
## Next [#next]
Readiness, background jobs, sockets and databases in depth.
When you can't run the backend yourself.
Pick a setup for each purpose.
# Frontend only (/docs/frontend-only)
You run the frontend; the API runs somewhere else. danwo covers the browser's side.
You work on the frontend, and the API runs elsewhere: a shared dev server, a staging API, or a
service another team owns. danwo starts your frontend and drives the browser. It sees every call
the browser makes, but nothing the remote API does.
## Set it up [#set-it-up]
Start the frontend behind danwo's proxy and point it at the remote API. Hosts with no emulator
pass through to the real thing:
```ts title="danwo.config.ts"
import { appEnvironment, defineConfig } from "@danwo/sdk"
export default defineConfig({
testDir: "tests",
environment: () =>
appEnvironment({
app: {
command: "npm run dev",
url: "http://localhost:3000",
env: { VITE_API_URL: "https://api.dev.example.com" },
},
services: [],
}),
})
```
`services` is empty here on purpose. Why is in the next section.
## Emulate a provider everywhere, or nowhere [#emulate-a-provider-everywhere-or-nowhere]
A provider must be the same on both sides. If the browser talks to an emulator and the remote API
talks to the real service, they won't agree:
| Provider | If you emulate it in the browser only |
| -------- | ----------------------------------------------------------------------------------------------------------------------- |
| Clerk | The browser gets a token the emulator signed. The remote API checks it with real Clerk and refuses it. |
| Stripe | The remote API creates a real payment; the browser's Stripe.js asks the emulator about it, which has never heard of it. |
| Webhooks | The real provider sends them to the remote API. danwo never sees them, so it can't fault them. |
So leave a provider out of `services` when the remote API also talks to it. Its calls pass
through to the real service. Use its test mode and test accounts.
Emulate a provider only when just the browser uses it, such as an upload that goes straight from
the browser to S3 with a presigned URL your frontend makes itself.
## What you get [#what-you-get]
- **UI tests, the agent, recordings and video.** All of it.
- **Faults on the browser's calls to the API.** The matrix can make the API answer 500, drop the
connection, or lose the answer after the server acted. That tests your frontend's error
handling. The last one really reaches the remote API, so use test accounts.
- **Clean state in the browser.** Cookies, storage and any emulators reset between tests.
## What you don't get [#what-you-dont-get]
- **The remote API's calls.** danwo can't emulate or fault what the remote server sends to Stripe
or its database.
- **Clean data on the server.** Tests that create records leave them there. Use unique names, or
clean up through the API.
- **Time travel on the server.** Only the page's clock moves.
A frontend team with no runnable backend could use an emulator of the API itself, built from its
OpenAPI description. That's planned; it isn't built yet.
## Next [#next]
If you can run the API locally, you get everything.
Testing a whole deployed environment.
Sign in once with a real test account, reuse it.
# Full-stack app (/docs/full-stack-local)
One app that serves both pages and API, started by danwo. Everything works.
Your app serves its pages and its API from one server: Next.js with API routes and server
actions, Remix, Nuxt, SvelteKit, or a plain Node server. danwo starts it, so every call it makes
goes through danwo's proxy. This is the setup with the most coverage.
## Set it up [#set-it-up]
Give danwo the command that starts your app and where it answers. List the services your app
calls:
```ts title="danwo.config.ts"
import { appEnvironment, defineConfig, resendService, stripeService } from "@danwo/sdk"
export default defineConfig({
testDir: "tests",
environment: () =>
appEnvironment({
app: { command: "npm run dev", url: "http://localhost:3000" },
services: [stripeService(), resendService()],
}),
})
```
danwo runs `npm run dev` with its proxy settings and a test certificate. When your server calls
`api.stripe.com`, the Stripe emulator answers. Your code doesn't change.
## What you get [#what-you-get]
| You can | How |
| --------------------------------------------- | ----------------------------------------------------------------------------- |
| Drive pages, with locators or the agent | [Agent and locators](/docs/agent-and-locators) |
| Emulate what the server and the browser call | the `services` list |
| Break any call, webhook or database statement | [`danwo matrix`](/docs/fault-matrix) |
| Start every test from clean state | [Isolation](/docs/isolation) |
| Move time forward | [Time travel](/docs/time-travel): the app's clock, the emulators', the page's |
## Next.js: routes, edge, middleware and server actions [#nextjs-routes-edge-middleware-and-server-actions]
We checked Next.js 16.4.2 under both `next dev` and `next start`. These all reached the Stripe
emulator through danwo's proxy:
| Where the call is made | `next dev` | `next start` |
| --------------------------------- | :--------: | :----------: |
| Route handler, Node runtime | ✓ | ✓ |
| Route handler, `runtime = "edge"` | ✓ | ✓ |
| Server component | ✓ | ✓ |
| Middleware | ✓ | ✓ |
The same app started without danwo reached the real Stripe from all four. Edge routes and
middleware work because, on your machine, Next.js runs them inside its own Node server process,
which danwo started.
Server actions run in that same server process, like server components, so their calls go
through the proxy too.
This holds when danwo starts the server on your machine. A deployment to an edge network (Vercel,
Netlify, Cloudflare) runs outside danwo: test it as [staging](/docs/staging).
## Pitfalls [#pitfalls]
- **A build that fetches during start-up.** Fonts or remote config fetched at build time go
through the proxy too. Pass `unmatched: "passthrough"` (the default) or add those hosts to
`allow` when you block the rest. The page's own images and fonts still load under block; see
[Outside hosts](/docs/outside-hosts).
- **A start script that sets `NODE_OPTIONS`.** danwo keeps its settings anyway, through a small
`node` wrapper on the app's `PATH`. See [Real stacks](/docs/real-stacks).
- **Other runtimes.** danwo covers Node and Bun servers. We haven't checked Deno, or
`wrangler dev` for Cloudflare Workers, yet.
## Next [#next]
A Stripe payment, checked from both sides.
When your frontend and API are separate apps.
Break every call your test makes, one at a time.
# Changed tests (/docs/impact)
Run only the tests a change can affect, and see why each one was picked.
`danwo test --changed` runs only the tests your change can affect. It knows what each test touched
in its last passing run, so a change to your webhook handler runs the tests that got that webhook,
and a change to the README runs nothing.
## Run it [#run-it]
```sh
npx @danwo/cli test --changed # uncommitted changes
npx @danwo/cli test --changed --since origin/main # everything since main
```
## How a test gets picked [#how-a-test-gets-picked]
A changed file picks a test when:
- it's the test file, or a file the test imports;
- a rule in your config maps it to a page, provider host, call or webhook the test touched.
A change nothing explains picks **every** test, so a lockfile or config change never skips one.
Markdown, plain text and `docs/` pick none.
## Map your source to what tests touch [#map-your-source-to-what-tests-touch]
Rules map globs to what a change there affects:
```ts title="danwo.config.ts"
import { defineConfig } from "@danwo/sdk"
export default defineConfig({
impact: {
rules: {
"src/webhooks.ts": ["webhook checkout.session.completed"],
"src/billing/**": ["api.stripe.com"],
"src/pages/checkout/**": ["/checkout"],
"src/server.ts": ["/"],
},
},
})
```
| Target | Picks the tests that |
| ------------------------------------------ | -------------------------------------- |
| `/checkout` | visited `/checkout` or a page under it |
| `/` | visited any page |
| `api.stripe.com` | called that host |
| `POST api.stripe.com/v1/checkout/sessions` | made that call |
| `webhook checkout.session.completed` | received that webhook |
A test with no data yet is picked by every rule that matches.
## See why [#see-why]
`danwo impact` prints what a change would pick, without running anything:
```sh
npx @danwo/cli impact src/webhooks.ts
```
```text
selected: 1 of 2 tests
tests/buy-credits.danwo.ts › buys 500 credits through Stripe Checkout
src/webhooks.ts (src/webhooks.ts) → webhook checkout.session.completed
matrix: danwo matrix tests/buy-credits.danwo.ts
```
The last line is the matrix for the picked tests, to run when the change touches payments.
```sh
npx @danwo/cli impact README.md
```
```text
ignored: README.md (no test depends on it)
selected: 0 of 2 tests
```
## Keep the index [#keep-the-index]
Each passing test writes what it touched to `.danwo/impact.json`. The file is sorted and has no
timestamps, so commit it: CI then picks tests from the same data you do.
| Option | Default |
| --------------- | -------------------------------------------------- |
| `impact.rules` | none: only test files and their imports pick tests |
| `impact.ignore` | `**/*.md`, `**/*.mdx`, `**/*.txt`, `docs/**` |
The **Impact** page in the [dashboard](/docs/dashboard) shows the same index.
## Next [#next]
Run changed tests on every pull request.
Every option in `danwo.config.ts`.
# Introduction (/docs)
danwo is an end-to-end testing framework for web and mobile apps. Test the UI, and add the backend when you need it.
danwo is an open-source end-to-end testing framework for web and mobile apps. A test opens your
app, clicks and types like a user, and checks what the screen shows. It runs in a browser, on
Android and on iOS.
```ts title="tests/sign-up.danwo.ts"
import { test } from "@danwo/sdk"
test("signs up", async ({ app, screen, expect }) => {
await app.open("/sign-up")
await screen.getByLabel("Email").fill("ada@example.com")
await screen.getByLabel("Password").fill("correct horse battery staple")
await screen.getByRole("button", { name: "Create account" }).click()
await expect(screen.getByRole("heading", { name: "Welcome, Ada" })).toBeVisible()
})
```
That is a whole test. danwo starts your app, opens a fresh browser and waits for each click to
settle, so there are no sleeps. It needs no providers, no API keys and no model.
## Add layers as you need them [#add-layers-as-you-need-them]
Everything past the first test is optional. Each layer adds to the one before, and you can stop at
any of them.
### 1. Locator tests [#1-locator-tests]
A **locator** finds an element the way a user names it: a button called "Pay", a field labelled
"Email". Locators never call a model. See [Agent and locators](/docs/agent-and-locators).
```ts title="tests/search.danwo.ts · a locator step"
await screen.getByRole("searchbox", { name: "Search" }).fill("blue mug")
await screen.getByRole("link", { name: "Blue mug" }).click()
await expect(screen.getByText("In stock")).toBeVisible()
```
### 2. Agent steps in plain English [#2-agent-steps-in-plain-english]
When a step is easier to say than to pin down, write it in English. The agent works it out once,
then danwo replays the recording with no model call. That goes for `assert`, `waitFor` and
`extract` too, not only actions. See [Agent steps](/docs/agent-steps).
```ts title="tests/checkout.danwo.ts · agent steps"
await agent.act("add two blue mugs to the basket and go to checkout")
await agent.assert("the basket shows two blue mugs")
await agent.waitFor("the delivery options have loaded")
```
### 3. Screenshots and visual comparison [#3-screenshots-and-visual-comparison]
`toHaveScreenshot` compares the page, or one element, with a baseline image kept next to the test.
Mask what changes on every run, like a clock. See [Visual comparison](/docs/visual-comparison).
```ts title="tests/home.danwo.ts · a visual check"
await expect(screen).toHaveScreenshot("home", { mask: [screen.getByTestId("clock")] })
```
Layers 1 to 3 are a complete end-to-end setup on their own: sign-up, log-in, search, forms and
checkout screens, on web and mobile, with video and a dashboard. [What you can
test](/docs/what-you-can-test) maps common journeys to the layer each needs.
## When the bugs live behind the screen [#when-the-bugs-live-behind-the-screen]
A screen can say "Thank you" while Stripe charged the card twice, or the receipt email never went
out. The next three layers check what a click did behind the page.
### 4. Emulated providers [#4-emulated-providers]
danwo starts your app behind an **egress proxy**, a proxy that every outgoing request goes
through. It answers Stripe, Clerk, Resend and about twenty more with local **emulators**. No
sandbox keys and no code changes. See [Emulators](/docs/emulators).
```ts title="danwo.config.ts · the environment"
environment: () => appEnvironment({ app, services: [stripeService(), resendService()] }),
```
### 5. Invariants [#5-invariants]
An **invariant** is a rule that must hold however the test went. It reads your database and the
emulator, and checks they agree. See [Writing invariants](/docs/invariants).
```ts title="tests/waitlist.danwo.ts · an invariant"
}).invariant(async ({ databaseUrl, resend, expect }) => {
expect(await sql(databaseUrl, "select id from signups where email = $1", [EMAIL])).toHaveLength(1)
expect(resendInbox(resend).list(EMAIL)).toHaveLength(1)
})
```
### 6. The fault matrix [#6-the-fault-matrix]
The [fault matrix](/docs/fault-matrix) replays a test once per thing that could go wrong: a
declined card, a 500, a duplicate webhook, a double-clicked button. The invariant judges each run.
```sh
npx @danwo/cli matrix tests/waitlist.danwo.ts
```
## How the backend layers fit [#how-the-backend-layers-fit]
Every run, at any layer, leaves a **flight record**: a video of the screen, every step and, with
the backend layers, every call, webhook and database statement. See [Flight
records](/docs/flight-records).
## Get started [#get-started]
Set danwo up and run your first UI test in a few minutes.
From a log-in to a payment charged once: which layer each needs.
When you're ready: check that a Stripe payment happens exactly once.
Local, staging or production: what works in each.
# Integration lint (/docs/integration-lint)
Every run is checked for risky provider usage, from the calls and webhooks danwo already saw, with nothing to write.
**Integration lint** reviews every run for mistakes that tests rarely catch: a secret key sent from
the browser, a payment trusted on its redirect, a retry that charges twice. It reads the calls and
webhooks danwo already recorded, so there's nothing to write.
## What you see [#what-you-see]
Findings print after `danwo test` and `danwo matrix`:
```text
integration lint: 2 errors, 1 warning
error secrets/key-in-browser the browser sent a server-only key (sk_test_…) to POST api.stripe.com/v1/customers
at tests/checkout.danwo.ts:9; Call the provider from your server with the secret key; …
```
They also go into the flight record, the failure page, GitHub check annotations and JUnit. A rule
runs only when the run used a provider it's about, so it stays quiet otherwise.
Some rules need a fault to see anything. The webhook signature and freshness rules, and
`payments/unconfirmed`, read `danwo matrix` trials that forged, aged or delayed a delivery. Run
the matrix on payment and webhook journeys to get them.
## The rules [#the-rules]
| Rule | Level | Flags |
| -------------------------------- | ------- | ---------------------------------------------------------------------- |
| `webhooks/accepts-forged` | error | a webhook signed with the wrong secret was accepted |
| `payments/unconfirmed` | error | value given after a hosted payment, with no verify call and no webhook |
| `resilience/duplicate-create` | error | a create retried without the first call's Idempotency-Key |
| `secrets/key-in-browser` | error | the browser sent a server-only key |
| `secrets/key-in-url` | error | a server-only key in a URL query string |
| `stripe/idempotency-key` | warning | a Stripe call that moves money, without an Idempotency-Key |
| `flutterwave/verify-transaction` | warning | a Flutterwave payment trusted without verifying the transaction |
| `webhooks/accepts-stale` | warning | a webhook with a signed timestamp too old to be fresh was accepted |
| `webhooks/rejects-valid` | warning | a correctly signed webhook was refused |
| `webhooks/slow-answer` | warning | a webhook answered slower than the provider waits |
| `resilience/retry-after-ignored` | warning | a 429 retried sooner than its Retry-After allowed |
| `oauth/missing-state` | warning | a sign-in started without a `state` parameter |
| `messages/duplicate-send` | warning | the same email, text or chat message sent twice for one action |
| `data/no-retry-on-serialization` | warning | a transaction given up after 40001 or 40P01 |
| `resilience/no-retry-on-read` | info | a read that failed transiently and wasn't tried again |
| `oauth/missing-pkce` | info | a sign-in started without PKCE |
| `secrets/publishable-on-server` | info | the server called a provider with a publishable key |
Each finding says how to fix it, and points at the test line where the call happened.
## Change levels [#change-levels]
```ts title="danwo.config.ts"
import { defineConfig } from "@danwo/sdk"
export default defineConfig({
testDir: "tests",
lint: {
rules: { "stripe/idempotency-key": "error", "resilience/no-retry-on-read": "off" },
failOn: "error",
},
})
```
| Setting | Values | Default |
| -------- | --------------------------------------------- | ----------------- |
| `rules` | each rule to `error`, `warn`, `info` or `off` | the levels above |
| `failOn` | `error`, `warning` | never fails a run |
Turn it off for one run with `--no-lint`, or for good with `lint: false`.
## Lint a past run [#lint-a-past-run]
`danwo lint` runs the current rules again over a run's flight records, without running anything:
```sh
npx @danwo/cli lint
```
```text
integration lint: no findings
```
Pass a run id to lint an older one. It needs the run's records, so it can't lint a run made with
`record: "off"`.
## Next [#next]
Where a finding shows on the failure page.
The faults the webhook rules need.
# Writing invariants (/docs/invariants)
An invariant is the rule the fault matrix checks after every trial. Write one by comparing your app with the provider.
An **invariant** is a check that must be true however a test went: whether the payment
succeeded, failed or was retried. You attach it to a test with `.invariant()`. The fault matrix
runs it after every trial.
## The shape of an invariant [#the-shape-of-an-invariant]
`.invariant()` takes a function with the same fixtures as the test. It runs after the test's
body, once background work like webhooks has finished:
```ts title="tests/buy-credits.danwo.ts"
import { type StripeFixtures, test as base } from "@danwo/sdk"
const test = base.extend()
test("buys 500 credits", async ({ app, screen, expect }) => {
await app.open("/")
await screen.getByRole("button", { name: "Buy 500 credits" }).click()
await screen.getByTestId("checkout-pay").click()
}).invariant(async ({ app, stripe, expect }) => {
const res = await stripe.fetch(
new Request("https://api.stripe.com/v1/checkout/sessions?payment_status=paid", {
headers: { authorization: "Bearer sk_test_any" },
}),
)
const { data } = await res.json()
const { credits } = await (await fetch(new URL("/api/credits", app.baseUrl))).json()
expect(credits).toBe(data.length * 500)
})
```
## Compare two sides [#compare-two-sides]
A good invariant reads the same fact from two places and checks they agree:
| Your app says | The provider says | Invariant |
| ---------------- | ----------------------------------- | --------------------- |
| credits granted | payments Stripe captured | equal |
| orders confirmed | confirmation emails Resend accepted | one email per order |
| replies posted | messages in the Slack channel | one reply per mention |
Read **your app's side** through its own API or database. Read **the provider's side** through
its emulator fixture.
To query your app's database from an invariant, see [Your app's data in tests](/docs/app-data).
## Reading the provider's side [#reading-the-providers-side]
Each emulator gives your test a fixture named after it. Ask it what your app did:
```ts title="tests/order-email.danwo.ts"
import { type ResendFixtures, resendInbox, test as base } from "@danwo/sdk"
const test = base.extend()
test("confirms the order by email", async ({ app, screen }) => {
await app.open("/checkout")
await screen.getByRole("button", { name: "Place order" }).click()
}).invariant(async ({ resend, expect }) => {
expect(resendInbox(resend).list("ada@example.com")).toHaveLength(1)
})
```
Every provider's page lists its fixture and helpers. See [Providers](/docs/providers).
## Rules of thumb [#rules-of-thumb]
- **Compare counts and totals, not the screen.** The screen may never load in a trial where a
call failed. The invariant still has to hold.
- **Allow a clean failure.** If the payment failed, zero credits and zero charges is correct.
Write the rule so both outcomes pass.
- **Never compare times or ids.** They change on every trial.
A test without an invariant can still go through the matrix. Then the test itself must pass under
every fault, which is a much stricter bar.
## Next [#next]
Run the matrix on a test with an invariant.
The bugs invariants catch most often, and the fixes.
# iOS builds (/docs/ios-build)
Build an Expo app as a Release iOS Simulator app, and reuse it until its sources change.
danwo tests a Release build of your app, with its JavaScript bundled in, so no Metro server runs
during tests. `danwo build-ios` makes that build for the Simulator, without code signing.
## Build it [#build-it]
```sh
npx @danwo/cli build-ios app
```
```text
built app/.danwo/builds/dd5eb4ac77e9aa9b.app in 111 s (log: app/.danwo/builds/build-ios.log)
```
`app` is the folder of your Expo project, which needs an `ios.bundleIdentifier` in its config.
danwo:
1. runs `expo prebuild --platform ios`, which also installs the CocoaPods;
2. lets the app make plain HTTP calls, which the proxy routes to your backend;
3. runs `xcodebuild` for the Simulator, Release, for this Mac's CPU only, unsigned.
HTTPS needs nothing in the build: danwo trusts its certificate in the simulator itself.
## Reuse it [#reuse-it]
The build is kept under `/.danwo/builds/`, keyed by a hash of the app's sources. An
unchanged app isn't built again. Force a rebuild with `--force`, and see the tools' output with
`--verbose`.
| Build | Time on an Apple silicon Mac |
| ------------------------- | ---------------------------- |
| first, with pods | about 2 minutes |
| after a JavaScript change | about 100 s |
| unchanged | instant: the kept build |
## Build it from the config [#build-it-from-the-config]
`buildIos` from `@danwo/expo` does the same inside your environment, so a test run always has a
current build:
```ts title="build.ts"
import { buildIos } from "@danwo/expo"
export async function currentApp(): Promise {
const build = await buildIos({ project: "app" })
return build.app
}
```
This build allows plain HTTP to any host. Use it only for testing; ship your store builds without
it.
## Close the in-app browser [#close-the-in-app-browser]
On Android, a Custom Tab closes when Checkout links back to the app. On iOS, a Safari view stays
over the app until the app closes it. Close it when the link arrives:
```tsx title="App.tsx · closing the Safari view"
useEffect(() => {
if (Platform.OS === "ios" && returned?.startsWith("danwoshop://"))
WebBrowser.dismissBrowser().catch(() => undefined)
}, [returned])
```
`openAuthSessionAsync` closes itself, but iOS first asks the user to allow the sign-in, and the
test taps **Continue** like a person would.
## Next [#next]
Install the build on a simulator and test it.
# iOS setup (/docs/ios-setup)
Get a Mac ready to test a React Native or Expo app on an iOS Simulator with danwo.
danwo runs the same tests, invariants and fault matrix against a React Native or Expo app on an
iOS Simulator. The app's calls, and the checkout page it opens, go through danwo's proxy, so
Stripe and the rest are emulated there too. This page gets the Mac ready.
## What you need [#what-you-need]
| Need | Why |
| -------------- | --------------------------------------------------------------- |
| a Mac | the iOS Simulator only runs on macOS |
| Xcode | it brings the Simulator, `simctl` and the compiler for your app |
| an iOS runtime | the iOS version the simulator runs; Xcode installs one |
| CocoaPods | only to build an Expo app (`danwo build-ios`) |
No simulator needs to exist: danwo creates its own the first time, an iPhone on the newest iOS
you have, and keeps it for later runs.
## Get Xcode ready [#get-xcode-ready]
### Install Xcode [#install-xcode]
Install it from the App Store and open it once, so it finishes installing its components.
### Point the tools at it [#point-the-tools-at-it]
The command-line tools alone have no Simulator. Select the full Xcode:
```sh
sudo xcode-select -s /Applications/Xcode.app
```
### Add an iOS runtime [#add-an-ios-runtime]
In Xcode, open **Settings → Components** and install an iOS Simulator runtime if none is listed.
## Check the Mac [#check-the-mac]
```sh
npx @danwo/cli device doctor --platform ios
```
```text
ok Xcode: Xcode 26.6, Build version 17F113 (/Applications/Xcode.app/Contents/Developer)
ok simctl: xcrun simctl
ok iOS runtimes: iOS 18.6, iOS 26.5
ok simulators: danwo creates "danwo iPhone" (iPhone 17 Pro, iOS 26.5) on first use
ok CocoaPods: 1.17.0
```
Without `--platform`, the doctor checks Android and iOS, and passes when either is ready.
## How the network is bound [#how-the-network-is-bound]
A simulator shares the Mac's network, but keeps its own proxy setting in memory. danwo sets that
setting for its simulator only, and trusts the run's certificate in the simulator's keychain. The
Mac's own network settings are never touched.
| What | Reaches the proxy |
| ------------------------- | ---------------------------------------------------------- |
| the app's own calls | yes: `fetch`, `URLSession` and libraries built on them |
| a Safari view | yes, the checkout page `WebBrowser.openBrowserAsync` opens |
| a sign-in session | yes, `ASWebAuthenticationSession` (`openAuthSessionAsync`) |
| other simulators, the Mac | no: they keep their own settings |
Apps that pin their TLS certificates ignore danwo's certificate, so their calls can't be emulated.
Turn pinning off in the build you test.
## What works on iOS today [#what-works-on-ios-today]
| Works | Not yet |
| --------------------------------------------- | --------------------------------------------------- |
| locators, `expect`, screenshots, the agent | `getByRole("heading")`: iOS reports no heading role |
| Safari views and sign-in sessions | physical iPhones, and keys other than Enter |
| the fault matrix, on one simulator or several | reading mail from a test, saved sessions |
| films of each attempt, the app's log | moving the device's own clock |
## Next [#next]
Build your Expo app for the Simulator.
The config and what a test can do on a simulator.
# iOS tests (/docs/ios-tests)
Bind an iOS Simulator to the run, then run the same tests, agent steps, invariants and matrix as on Android.
An iOS test is the same file as an Android test. `screen` finds elements on the simulator's
screen, `expect` waits for checks, and an invariant compares your backend with Stripe. danwo boots
the simulator, routes its traffic through the proxy, and installs your build.
## A test on a simulator [#a-test-on-a-simulator]
```ts title="tests/buy-credits.danwo.ts"
import type { DeviceFixtures } from "@danwo/device"
import { type StripeFixtures, test as base } from "@danwo/sdk"
const test = base.extend()
test("buys 500 credits", async ({ screen, expect, device }) => {
await screen.getByTestId("buy").tap()
/* Checkout opens in a Safari view; the same locators read the web page. */
expect((await device.foreground()).browser).toBe(true)
await screen.getByLabel("Email").fill("buyer@example.com")
await screen.getByRole("button", { name: "Pay $5.00" }).tap()
await expect(screen.getByTestId("credits")).toHaveText("Credits: 500")
})
```
`getByTestId` matches React Native's `testID`, which iOS reports as the accessibility identifier.
A tap the keyboard covers, such as **Pay** under a field just filled, puts the keyboard away first.
As on Android, a tap waits for its element to be enabled and finds it again on the latest screen.
## Bind the simulator in the config [#bind-the-simulator-in-the-config]
`withIosDevice` wraps your environment and binds a simulator to it:
```ts title="danwo.config.ts · the device"
return withIosDevice(environment, {
worker: workerIndex,
app: { bundleId: "com.danwo.shopmobile", app: build.app, scheme: "danwoshop" },
})
```
The full file, which runs on Android or iOS by `DANWO_PLATFORM`, is in
[`examples/shop-mobile`](/docs/examples/shop-mobile). For each worker, danwo:
1. finds its simulator by name, or creates it, and boots it headless;
2. trusts the run's certificate in the simulator's keychain;
3. sets the simulator's own proxy to danwo's;
4. installs the `.app`.
| Option | What it does |
| ----------------- | -------------------------------------------------------------------- |
| `name` | the simulator's name; default `danwo iPhone` |
| `runtime` | the iOS to create it on: `iOS 26.5`, `26.5` or an identifier |
| `deviceType` | the device to create: `iPhone 17 Pro`; default the newest iPhone Pro |
| `udid` | uses a simulator that already exists, as it is |
| `worker` | gives each worker its own simulator: `danwo iPhone 2`, and so on |
| `remove` | deletes a simulator this run created when it closes |
| `app.launchArgs` | arguments the app is launched with |
| `app.env` | environment the app is launched with |
| `app.permissions` | `simctl privacy` services to grant before each launch |
Pass `blocked: IOS_BACKGROUND_HOSTS` from `@danwo/device` to your services. It blocks the
simulator's own background traffic (Safari's safe browsing, Apple's services), so the matrix never
faults it.
## Run it [#run-it]
```sh
DANWO_PLATFORM=ios npx @danwo/cli test
```
```text
✓ tests/agent-buy.danwo.ts › buys 500 credits when asked in plain words 35842 ms, worker 0
✓ tests/buy-credits.danwo.ts › buys 500 credits through Stripe Checkout in the in-app browser 11239 ms, worker 0
2 passed
cache: 3 replayed · 0 relocated · 0 handed off · 0 missed
```
Every attempt starts the app afresh: it is stopped, its data container emptied, and launched
again. Agent steps record per platform, so steps recorded on Android don't replay on iOS: record
them once on each.
## What a test can do [#what-a-test-can-do]
| Fixture | On iOS |
| ---------- | ----------------------------------------------------------------------------------------------- |
| `screen` | `getByTestId`, `getByLabel`, `getByText`, `getByRole` (no headings); `tap`, `fill`, `longPress` |
| `expect` | the same checks as the web, including `toHaveScreenshot` |
| `app.open` | opens a deep link; with no path, restarts the app |
| `device` | `foreground()`, `back()`, `openLink(url)`, `logs()`, `swipe()`, `dismissKeyboard()` |
| `agent` | `act`, `assert`, `extract`, `waitFor`, with device tools |
`back()` taps the screen's own back or close control, since an iPhone has no Back button.
`swipe()` takes the same options as on [Android](/docs/android-tests#swipe-and-put-the-keyboard-away).
## Run the matrix on several simulators [#run-the-matrix-on-several-simulators]
`iosWorkers` picks a worker count from the Mac: a simulator takes about 2 GB and two cores.
```sh
DANWO_PLATFORM=ios npx @danwo/cli matrix tests/buy-credits.danwo.ts --workers 2
```
The example shop's matrix, on a 12-core, 32 GB Apple silicon Mac:
| Matrix | Simulators | Wall time | Median trial |
| ------------------------------ | ---------- | --------- | ------------ |
| `buy-credits`, 23 runs | 1 | 370 s | 13.4 s |
| `agent-buy`, 23 runs, replayed | 2 | 298 s | 13.4 s |
## Next [#next]
What the Mac needs, and how the network is bound.
The same tests on an Android emulator.
# Isolation (/docs/isolation)
Every test, and every retry, starts from the same seeded state, so no test depends on another one.
**Isolation** means each test starts from a clean slate: the emulators back at their seeds, no
queued webhooks, the clock at now. A test never sees what the test before it left behind, so tests
pass in any order and a retry runs exactly like the first attempt.
## What's reset before each test [#whats-reset-before-each-test]
| Part | Reset to |
| ---------------------- | ----------------------------------------------------------- |
| Every emulator | its seed: customers, mail, sign-ins, connected accounts |
| Webhooks and the proxy | empty queues, an empty call log |
| The clock | now, after any time travel |
| Your data stores | their snapshot, when you give one (Postgres, SQLite, Redis) |
| The browser | a fresh context: no cookies, no storage |
It's fast: restoring four emulators takes about a tenth of a millisecond, and a test-sized
Postgres database tens of milliseconds.
## Saved sessions keep their sign-in [#saved-sessions-keep-their-sign-in]
A [saved session](/docs/sessions) signs a user in once per worker. danwo keeps a snapshot of the
state right after that sign-in, next to the clean one:
A test that uses `ada` starts signed in as Ada, without signing in again. A test that uses no
session starts signed out, even if it runs right after one that used Ada.
## Share state when you mean to [#share-state-when-you-mean-to]
Some files are a story, where each test builds on the last. Set `isolation` for that file:
```ts title="tests/onboarding.danwo.ts"
import { test } from "@danwo/sdk"
test.use({ isolation: "file" })
test("creates a workspace", async ({ app, screen }) => {
await app.open("/onboarding")
await screen.getByLabel("Workspace name").fill("Acme")
await screen.getByRole("button", { name: "Create" }).click()
})
test("invites a teammate to it", async ({ app, screen }) => {
await app.open("/settings/members")
await screen.getByLabel("Email").fill("bob@acme.test")
await screen.getByRole("button", { name: "Invite" }).click()
})
```
| Value | What it does |
| -------- | ------------------------------------------------------------ |
| `"test"` | Default. Every test and retry starts from the seeded state. |
| `"file"` | Resets once per file. The file's tests build on one another. |
| `"none"` | Never resets. State carries from file to file in a worker. |
Set it for the whole project with `isolation` in `danwo.config.ts`, or per file or describe
block with `test.use({ isolation })`.
## Reset your app's own memory [#reset-your-apps-own-memory]
danwo resets the emulators, the clock and the stores you give it. It can't reach into your app's
memory. If your app caches things in memory, give it a test-only reset endpoint, and call it from
the environment's `restore`:
```ts title="danwo.config.ts"
import { appEnvironment, defineConfig, stripeService } from "@danwo/sdk"
const url = "http://localhost:3000"
export default defineConfig({
testDir: "tests",
async environment() {
const env = await appEnvironment({
app: { command: "npm run dev", url, env: { APP_TEST_RESET: "1" } },
services: [stripeService()],
})
return {
...env,
async restore(slot) {
await env.restore?.(slot)
await fetch(new URL("/__test/reset", url), { method: "POST" })
},
}
},
})
```
Only turn the reset endpoint on in tests. Here the app enables it when `APP_TEST_RESET` is set,
which danwo passes to the app it starts and nowhere else.
## Parallel workers never share [#parallel-workers-never-share]
Each worker has its own app, emulators and proxy. Two workers never see each other's state, so
`workers: 4` is safe without any changes to your tests.
## Next [#next]
Sign in once per worker, and start tests already signed in.
Snapshot your Postgres or Redis, and fault it in the matrix.
# Live runs and reruns (/docs/live-runs)
Watch tests and matrix trials stream into the dashboard as they run, and start, rerun or cancel runs from the page.
While a run is going, the dashboard shows it live. You can also start runs from the page: rerun
what failed, run one test, or run the matrix for a test.
## Watch a run as it happens [#watch-a-run-as-it-happens]
Start a run as usual:
```sh
npx @danwo/cli test
```
Open **Runs**. The run shows under **Running now**, and its page fills in as it goes: tests and
trials as they start and finish, each step, the calls, webhooks and faults behind it, the elapsed
time and the model spend. The browser tab's title and icon show whether it's passing.
A run started from a terminal shows up too.
## Start a run from the page [#start-a-run-from-the-page]
| Where | Button | Runs |
| ----------------- | ----------------------------- | ---------------------------------------------------- |
| a test run | **Rerun failed** | the run's failed tests |
| a test's row | **Rerun** | that test |
| Tests | **Run selected**, **Run all** | the tests you picked, or all of them |
| Matrix, or a test | **Run the matrix** | its matrix, optionally only faults matching a filter |
| a running run | **Cancel** | stops it |
The dashboard runs danwo's CLI with fixed arguments, for tests your project lists. It runs one at
a time; more requests wait in a queue.
## Next [#next]
Open what a run recorded.
Every page of the dashboard.
# Planning and budget (/docs/matrix-planning)
How danwo picks which faults to run, how to preview the plan, and how to run more or fewer.
A real app's test can make hundreds of calls and thousands of database statements. Faulting
every one would take hours. So danwo **plans**: it picks the faults most likely to find a bug,
and stops at a **budget** of 40 trials.
## Preview the plan [#preview-the-plan]
`--plan` runs the clean trial, prints what it would run, and stops:
```sh
npx @danwo/cli matrix tests/buy-credits.danwo.ts --plan
```
```text
clean run passed; 18 faults planned
status 500 on POST api.stripe.com/v1/checkout/sessions
reset-after on POST api.stripe.com/v1/checkout/sessions
duplicate on webhook checkout.session.completed
forged on webhook checkout.session.completed
double-submit on POST /buy
…
about 13–24 s for 18 trials on 2 workers
```
Use it before a long run, or to check a fault you expect is in the plan.
The time is a range built from the clean trial's own phases. Each trial pays a restore of the
starting state (or a reset, when nothing is snapshotted), then the browser, the body and the
close. A delay fault adds its delay, up to how long a check waits for it. A fault that breaks the
run, like a reset or a 500, can end its body early, which is why the low end is lower.
## Only the test's body [#only-the-tests-body]
Faults land only in the test's **body**: the code inside `test(…)`. Everything before it is
**setup** and gets no faults:
- signing in with a saved session,
- fixtures,
- `beforeEach` hooks.
Setup is still part of every trial. It just runs cleanly.
## How a big plan is cut down [#how-a-big-plan-is-cut-down]
A plan that fits the budget runs whole. When it doesn't, danwo narrows it in this order, the same
way every time:
| Step | What goes |
| --------------------- | ------------------------------------------------------------------------- |
| 1. Background work | Health checks, metrics, static files, token refreshes. |
| 2. Repeats | A call or statement repeated in the body is faulted only the first time. |
| 3. Lesser fault kinds | Each call keeps its most telling faults, such as a 5xx and a lost answer. |
| 4. Over the budget | Races and webhooks are kept first, then writes, then sockets, then reads. |
The run tells you what it left out:
```text
1999 of 2039 possible faults left out: 1608 on setup, 278 on repeats of a route or statement,
71 lesser fault kinds, 42 over the budget
```
## Run more, or fewer [#run-more-or-fewer]
| Option | What it does |
| -------------------- | -------------------------------------------------------------------------------------- |
| `--budget ` | A different budget, such as `--budget 100`. Set it for a project with `matrix.budget`. |
| `--all` | Every fault on the body, with no budget. |
| `--thorough` | Every fault kind on each call, not only the most telling ones. |
| `--every-occurrence` | Faults on every repeat of a call, not only the first. |
| `--include-setup` | Faults on the setup too, such as signing in. |
```sh
npx @danwo/cli matrix tests/checkout.danwo.ts --budget 100 --thorough
```
Run the default plan on every pull request. Save `--all` and `--thorough` for a nightly run.
## Next [#next]
What each kind of fault does.
Every option of `danwo matrix`.
# Reading results (/docs/matrix-results)
Find the failed trials in the dashboard, and see what each fault did to your app.
The terminal tells you which trials failed. The dashboard shows you why: the fault, the moment
it hit, and what your app did next.
## Open the matrix run [#open-the-matrix-run]
```sh
npx @danwo/cli serve --open
```
Go to **Runs** and pick the `danwo matrix` run, or open **Matrix** to see every test's latest
result.
## The grid [#the-grid]
The run's page is a grid. Each **row** is something the clean run did: a call to Stripe, a
webhook, a form post. Each **column** is a kind of fault. Each cell is one trial.
| Colour | Meaning |
| ------ | ------------------------------------------------ |
| Green | The invariant held. |
| Red | The invariant broke. This is a bug. |
| Blue | Not judged: the trial couldn't run to a verdict. |
| Grey | Invalid: the fault never happened in that trial. |
| Empty | No such fault for that call. |
Prefer a list? Switch the view from **Grid** to **List**.
## Open a failed trial [#open-a-failed-trial]
Click a red cell. A card shows:
- **The fault**, such as `duplicate on webhook checkout.session.completed`.
- **What the invariant said**, such as `expected 1000 to be 500`.
- **Where the test stopped**, with the file and line.
Click **Open the flight record** to watch the trial.
## Watch what happened [#watch-what-happened]
The recording opens in two panes. On the left are the test's steps. On the right:
| Tab | What you see |
| ------------------ | ---------------------------------------------------- |
| **Replay** | The video, with a marker where the fault hit. |
| **Providers** | Every call and webhook, with the faulted one marked. |
| **Effects** | What changed behind the app after each step. |
| **Emulator state** | What each provider held when the trial ended. |
| **Matrix** | The other trials of the same run. |
For the duplicate webhook above, **Providers** shows `checkout.session.completed` delivered
twice, and **Effects** shows the credits going up twice.
## Hand it to your coding agent [#hand-it-to-your-coding-agent]
**Copy prompt to fix** copies the failure, the fault and the relevant calls as a prompt. Paste
it into Claude Code, Cursor or Codex.
## Next [#next]
The usual failures, and how to fix each.
The rest of the dashboard.
# MCP tools (/docs/mcp-tools)
Every tool, resource and prompt danwo's MCP server gives a coding agent, and what each is for.
danwo's MCP server is what your agent calls to work with your project. Start it with
`npx @danwo/cli mcp`; [Set up your agent](/docs/agent-setup) registers it for you. This page lists
everything it offers.
## Run and explain tests [#run-and-explain-tests]
The tools an agent uses most. They work on the project's tests as `danwo test` would:
| Tool | What it does |
| ----------------- | -------------------------------------------------------------------------------------------------------------- |
| `list_tests` | Lists the tests by file, with skip/only, and whether each has an invariant. |
| `run_tests` | Runs all tests, or some by file, grep or name, and reports each failure's step, error and record. |
| `run_matrix` | Runs the [fault matrix](/docs/fault-matrix) on one test: a clean run, then a run per fault, judged. |
| `show_run` | Explains a recorded run: the error, the steps, console errors, provider calls and webhooks. |
| `read_screenshot` | Returns a screenshot from a flight record: the screen at failure, or after a step. |
| `scaffold_test` | Writes a new `*.danwo.ts` file: the page it opens, its steps, and an invariant stub. |
| `inspect_page` | Opens one page of your app through the project's environment and returns its accessibility tree and its calls. |
Pass `format: "md"` to `show_run` to get the same failure page `danwo show --format md` prints.
## Drive the app step by step [#drive-the-app-step-by-step]
A **session** is your app opened in danwo's browser, with its emulators and proxy, for the agent
to explore before it writes a test. Every action answers with the page as it is now:
| Tool | What it does |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `open_session`, `close_session` | Starts the app, emulators and browser (or a device), or stops them. |
| `navigate` | Goes to a path or URL, or back, forward, reload. |
| `click`, `hover`, `fill`, `press`, `select`, `check` | Acts on an element, by a ref from the last snapshot or by a locator. |
| `fill_secret` | Fills a field with a configured secret by name. The value never appears anywhere. |
| `upload` | Puts files into a file input, or the file picker a button opens. Up to 10 MB a call. |
| `drag` | Drags an element (`target`) and drops it on another (`to`), such as a card onto a column. |
| `tabs`, `switch_tab`, `close_tab` | Lists the open tabs, moves actions to another, or closes one. |
| `dialog` | Answers the alert, confirm or prompt the page has open: OK or Cancel, with text for a prompt. |
| `snapshot`, `screenshot` | The page as an accessibility tree with refs, or as an image with secrets masked. |
| `check_locator` | Checks a locator matches exactly one element, and returns the line to paste. |
| `start_recording`, `stop_recording` | Records the session's screenshots, and video when no secret was filled. |
### Find an element inside another [#find-an-element-inside-another]
A target is a ref from the last snapshot, or a locator as a test writes it. When one locator
matches several elements, chain it inside the part of the page that holds the one you want:
```ts title="target · fragment"
getByRole("region", { name: "Cart" }).getByRole("button", { name: "Remove" })
```
A chain can also narrow what it found:
| Link | What it keeps |
| ------------------------- | ------------------------------------ |
| `.getBy…(…)` | Matches inside each element so far. |
| `.filter({ hasText: … })` | The elements that contain this text. |
| `.first()`, `.last()` | The first or last element. |
| `.nth(n)` | The element at index `n`, from 0. |
`check_locator` takes chains too. When a chain matches nothing, it names the first link after
which nothing matched, so you know which part to fix.
### Click with keys held, twice or with the right button [#click-with-keys-held-twice-or-with-the-right-button]
`click` takes three options beyond its target:
| Option | What it does | Default |
| ----------- | -------------------------------------------------------------- | -------- |
| `modifiers` | Keys held during the click: `Shift`, `Alt`, `Meta`, `Control`. | none |
| `button` | `right` opens a context menu; `middle` is the wheel button. | `"left"` |
| `double` | `true` double-clicks, such as to edit a cell or select a word. | `false` |
`ControlOrMeta` is Meta on macOS and Control elsewhere. Use it to add an item to a selection or
open a link in a new tab, so the same step works on every machine.
### Follow a new tab [#follow-a-new-tab]
When the app opens a tab, such as a popup or a link to a new window, actions go to it straight
away. The answer says so:
```text
clicked screen.getByRole("link", { name: "Open receipt" })
tab: 2 of 2 (actions now go to it)
url: http://localhost:3000/receipt/42
```
`tabs` lists every open tab by number. `switch_tab` moves back to one, and `close_tab` closes one,
or the active tab without a number. When the active tab closes, the newest one left takes over.
### Answer a dialog [#answer-a-dialog]
A dialog is the box `alert`, `confirm` or `prompt` opens. It blocks the page until someone answers
it, so danwo holds it open and the answer tells your agent:
```text
a confirm dialog is open: "Empty the basket?"
the page waits for an answer: dialog with accept: true (OK) or false (Cancel)
```
Call `dialog` with `accept: true` for OK or `false` for Cancel.
Add `text` to type into a prompt; without it, the prompt keeps its default. The click that opened
the dialog then finishes, and the answer shows what changed. Until then, every other page tool
says the dialog is open.
### Upload a file [#upload-a-file]
`upload` takes a list of files. Give each one in whichever form you have it:
| Form | What it is |
| ------------------ | --------------------------------------------------------- |
| `{ path }` | A file in your project, relative to the project folder. |
| `{ file }` | A file your config offers tests by name, under `files`. |
| `{ name, text }` | A file the agent writes on the spot, such as a small CSV. |
| `{ name, base64 }` | Any other bytes, such as an image. |
```json title="upload · arguments"
{
"session": "s1",
"target": "getByLabel(\"Receipt\")",
"files": [{ "path": "fixtures/receipt.pdf" }, { "name": "notes.csv", "text": "item,qty\noats,2" }]
}
```
A test uploads by name only, so a recording never holds a path. Add the file under `files` in
`danwo.config.ts`, then call `setInputFiles("receipt")`. An empty list clears the input.
## Drive a mobile app [#drive-a-mobile-app]
A **device session** drives your app on the Android emulator or iOS Simulator your config's
environment sets up, in place of a browser. Open one with `device: true`:
```json title="open_session · arguments"
{ "device": true }
```
The app starts afresh, and the answer is its screen. It reads like a page's tree, with a line
that says what is in front:
```text
session s1 is open.
device: iOS Simulator
in front: the app com.danwo.shopmobile
- heading "Danwo Shop" [ref=e2]
- text "Credits: 0" [testId=credits] [ref=e3]
- button "Buy 500 credits" [testId=buy] [ref=e4]
```
The same tools work, the way a person uses a phone:
| Tool | On a device |
| --------------------------- | ------------------------------------------------------------------- |
| `click` | Taps the element. |
| `fill`, `fill_secret` | Types into a field, replacing what it holds. |
| `check` | Taps a switch or checkbox, unless it already shows that state. |
| `press` | Taps the target, then presses the key. iOS has Enter only. |
| `navigate` | Opens a path by the app's link scheme, a full URL, or goes `back`. |
| `back` | Goes back: Android's Back button, or the screen's back control. |
| `scroll` | Scrolls the screen, or one list, up, down, left or right. |
| `swipe` | Swipes a finger across the screen or an element, for `duration_ms`. |
| `long_press` | Presses and holds an element. |
| `dismiss_keyboard` | Puts the on-screen keyboard away. |
| `tap_at` | Taps a point, in screenshot pixels, for what the tree can't show. |
| `screenshot` | The screen, with password and secret fields painted over. |
| `snapshot`, `check_locator` | The screen's tree, and locators checked against it. |
Locators take the same chains as on a page. Tools only a browser has, such as `hover` or `drag`,
answer with an error that says so. When the app opens a browser, such as a checkout page, the
answer's `in front` line names it and its URL.
Before each action, danwo reads the screen again. If the element a ref names has moved or gone,
nothing is tapped: the answer says so and shows the screen as it is now, with fresh refs.
## Look behind the app [#look-behind-the-app]
These show what the app did at its providers during a session. They're how an agent finds the
right invariant:
| Tool | What it does |
| ------------------ | -------------------------------------------------------------------------------------------- |
| `exchanges` | The calls the app and browser made through danwo's proxy since the last look. |
| `deliver_webhooks` | Delivers the webhooks the emulators have queued, then lists the deliveries. |
| `emulator_state` | What each emulated service holds now: Stripe objects, sent mail, Slack messages, S3 objects. |
| `inject_fault` | Arms one fault, named as the matrix names it, to fire on the next matching call or delivery. |
| `clear_faults` | Disarms the faults that haven't fired yet. |
## Resources and the prompt [#resources-and-the-prompt]
The server also offers **resources** your agent can read, and one **prompt**:
| Name | What it is |
| ----------------------- | ------------------------------------------------------------------------------ |
| `danwo://config` | Where tests live, how the app runs, the model, and your secrets' names. |
| `danwo://runs/latest` | The newest run, with its failures explained. |
| `danwo://spec` | Every API, option, fault label and error code, generated from danwo's code. |
| `danwo://guide/` | A task guide, such as `first-test`, `matrix` or `debugging`. |
| `write-danwo-test` | A prompt: inspect the app, scaffold a test, run it and fix it until it passes. |
Secrets stay hidden from the agent everywhere: answers, screenshots, recordings and records are
all redacted. A video can't mask a typed secret, so `start_recording` refuses video once one is
filled.
## Next [#next]
Watch these tools work together on a failing test.
The fault names `inject_fault` and the matrix understand.
# Android setup (/docs/mobile)
Get this machine ready to test a React Native or Expo app on an Android emulator with danwo.
danwo runs the same tests, invariants and fault matrix against a React Native or Expo app on an
Android emulator. The app's calls go through danwo's proxy, so Stripe and the rest are emulated
there too. This page gets the machine ready.
## What you need [#what-you-need]
| Need | Why |
| ------------------------------ | ---------------------------------------------------------------------------- |
| Android Studio | it brings the Android SDK (`adb`, the emulator) and a JDK |
| a **Google APIs** system image | danwo needs root to trust its certificate; Play Store images can't be rooted |
| an AVD | the virtual device danwo boots |
## Create the emulator [#create-the-emulator]
### Install Android Studio [#install-android-studio]
Install it, open it once, and let it download the SDK.
### Add a Google APIs image [#add-a-google-apis-image]
In **Device Manager**, create a device and pick a system image whose target says **Google APIs**,
not Google Play. On Apple silicon use `arm64-v8a`; on Intel and Linux, `x86_64`:
```text
system-images;android-34;google_apis;arm64-v8a
```
### Name the AVD [#name-the-avd]
Give it a short name, such as `danwo_spike`. Your config refers to it by that name.
## Check the machine [#check-the-machine]
```sh
npx @danwo/cli device doctor --platform android
```
```text
ok Android SDK: ~/Library/Android/sdk
ok adb: ~/Library/Android/sdk/platform-tools/adb
ok emulator: ~/Library/Android/sdk/emulator/emulator
ok system images: rootable: system-images/android-34/google_apis/arm64-v8a
ok AVD danwo_spike: system-images/android-34/google_apis/arm64-v8a
ok JDK: /Applications/Android Studio.app/Contents/jbr/Contents/Home
```
Each line is `ok`, `warn` or a failure with what to do. It also warns about an emulator left with a
proxy setting from an earlier run, and about images missing their files.
Apps that pin their TLS certificates ignore danwo's certificate, so their calls can't be emulated.
Turn pinning off in the build you test.
## What works on Android today [#what-works-on-android-today]
| Works | Not yet |
| -------------------------------------------- | ----------------------------------------------- |
| locators, `expect`, screenshots, the agent | `hover`, `setInputFiles`, `dragTo` |
| Custom Tabs (a web checkout inside the app) | reading mail from a test, saved sessions |
| the fault matrix, on one emulator or several | Flutter apps (their accessibility tree is thin) |
| time travel for your services and backend | moving the device's own clock |
## Next [#next]
Build your Expo app so it trusts danwo's proxy.
The config and what a test can do on a device.
The same tests on an iOS Simulator.
# The Node preload (/docs/node-preload)
How danwo routes a Node app's calls through its proxy without code changes, and what to do when a start script gets in the way.
Your app calls `api.stripe.com` and gets danwo's emulator, without a line of code changed. For a
Node app, a small **preload** does that: a module Node loads before your app starts, which sends
every outgoing HTTP call through danwo's proxy and trusts its certificate.
## How it gets in [#how-it-gets-in]
danwo adds the preload to `NODE_OPTIONS` in each app's environment, together with the proxy's
address and the run's CA certificate. Calls made with Node's own HTTP clients, axios and most SDKs
then go through the proxy. That includes clients that build their own agent:
| Client | Routed |
| ------------------------------------------ | ---------------------------------------------------------- |
| `fetch`, `http`, `https` | ✓ |
| Stripe's client with its own `https.Agent` | ✓ |
| axios with a custom agent | ✓ |
| `fetch` with an undici `Agent` | ✓ |
| a server-side WebSocket client | only with a proxy agent ([why](/docs/sockets-and-streams)) |
## Your app's Node version [#your-apps-node-version]
danwo itself needs Node 24. Your app can run on an older Node: a package pinned by volta, mise or
asdf, a script that calls a specific `node`, or a Docker image. The preload routes **Node 20 and
later**. Newer Nodes do part of the work themselves, and the preload does the rest:
| Node running the app | Who routes `fetch` | Who routes `http`, `https` and their agents |
| ------------------------------------- | ------------------ | -------------------------------------------- |
| 24.5 and later, 22.21 and later on 22 | Node | Node, with the preload's help for SDK agents |
| 24.0 to 24.4 | Node | the preload |
| 20, 22.0 to 22.20, 23 | the preload | the preload |
| 18 and older | not supported | not supported |
Where Node can't route a call, the preload opens the tunnel through the proxy itself and follows
`NO_PROXY` as Node does.
### The check at start [#the-check-at-start]
Before your app's code runs, the preload calls a probe host that only danwo's proxy answers, with
`fetch`, an `https.Agent` and an `http.Agent`. A process counts as routed only when all three reach
the proxy. If one misses, the process says so on stderr, naming its pid, its Node version and what
to do:
```text title="stderr · a process that misses the proxy"
danwo: process 41872 runs Node v22.18.0, and its calls do not go through danwo's proxy
(fetch (ENOTFOUND) missed it): they would reach the real providers. …
```
What danwo does next depends on the run:
| Run settings | A process that misses the proxy |
| ----------------------------------------- | ------------------------------------------------------- |
| `unmatched: "block"` or `secrets: "fail"` | stops at start, and danwo refuses the run with its name |
| anything else | keeps running; danwo warns and adds it to the run notes |
The usual cause is a `NO_PROXY` that names every host (`*`) or the providers' hosts.
## When a start script sets `NODE_OPTIONS` [#when-a-start-script-sets-node_options]
Many start scripts set `NODE_OPTIONS` themselves:
```json title="package.json · a start script"
{ "scripts": { "start": "NODE_OPTIONS=--max-old-space-size=8192 nest start" } }
```
That replaces danwo's value, and the preload is lost. danwo handles it for you: it puts a small
`node` shim first on each app's `PATH`, which loads the preload whatever `NODE_OPTIONS` says. Your
script keeps its own flags too.
A `NODE_OPTIONS` you set in an app's `env` in the config is joined with danwo's, not replaced:
```ts title="danwo.config.ts · joined flags"
{ name: "api", command: "pnpm start", url: "http://localhost:4000",
env: { NODE_OPTIONS: "--max-old-space-size=4096" } }
```
## When a process still escapes [#when-a-process-still-escapes]
Each process the preload is in leaves a heartbeat. After the apps start, danwo warns about any Node
process of an app that runs without it, and says how to fix it. The usual causes:
| Cause | Fix |
| ---------------------------------------------------------- | ------------------------------------------------------- |
| the script runs Node by an absolute path (`/usr/bin/node`) | call `node` from `PATH` |
| the app spawns Node with a clean environment | pass `process.env` to the child |
| the shim is off (`nodeShim: false`) | turn it back on, or set `NODE_OPTIONS` in `env` instead |
| the app runs Node 18 or older | run it on Node 20 or later |
A process without the preload calls the real hosts, and `unmatched: "block"` can't stop it: its
calls never reach the proxy.
## Next [#next]
What happens to calls no emulator answers.
What danwo stops when a run ends.
# Outside hosts (/docs/outside-hosts)
Decide what happens to calls no emulator answers, keep analytics out of your runs, and catch live keys before they leak.
danwo emulates the providers you list. Your app may call other hosts too: a search service, a
logo API, an analytics tracker. You decide what happens to those calls, so a test run never
sends real data out by accident.
## Pass through or block [#pass-through-or-block]
`unmatched` sets what happens to hosts no service answers:
| `unmatched` | Calls to unemulated hosts | Use it when |
| ----------------------- | ------------------------- | --------------------------- |
| `passthrough` (default) | go to the real host | you're starting out |
| `block` | get a 502 from the proxy | you want the run sealed off |
```ts title="danwo.config.ts"
import { appEnvironment, defineConfig, stripeService } from "@danwo/sdk"
export default defineConfig({
testDir: "tests",
environment: () =>
appEnvironment({
app: { command: "npm run dev", url: "http://localhost:3000" },
services: [stripeService()],
unmatched: "block",
allow: ["fonts.googleapis.com"],
}),
})
```
Start with `block`. A 502 in the flight record names every host your app needs, so you know what
to emulate, allow or start locally.
## What still passes under block [#what-still-passes-under-block]
Blocking stops your app's calls to the real world. It doesn't stop the page from showing a logo
from a CDN. Under `block`, these still go through:
| Call | Under `block` |
| ------------------------------------------------------- | ---------------------------- |
| to this machine (`localhost`, `127.0.0.1`, `::1`) | passes |
| to a host in `allow` | passes |
| a page's image, font, stylesheet, video, audio or track | passes, recorded as an asset |
| a page's script, frame, worker, or `fetch()` | 502 |
| a server's own call, or any call that isn't a GET | 502 |
| to a telemetry host or a host in `blocked` | 502, even for an image |
The browser says what each request loads, so danwo can tell a logo from a data call to the same
host. A script is never let through: it runs code that calls on.
## Missing images or fonts in a recording [#missing-images-or-fonts-in-a-recording]
Images and fonts go missing when you set `assets: "block"`, which refuses page assets too, or when
the page loads them with `fetch()`. Either way, the run names the hosts once at the end:
```text
Blocked hosts the pages tried to load: cdn.sanity.io (19 images), api.example.com (2 requests).
Add the ones the app needs to `allow`, or set `assets: "passthrough"` for images and fonts.
```
The same line is a note on the attempt's flight record, so it shows in the dashboard too. Allow
the hosts the page needs, or go back to the default:
```ts title="danwo.config.ts (fragment)"
appEnvironment({
app: { command: "npm run dev", url: "http://localhost:3000" },
unmatched: "block",
assets: "passthrough",
allow: ["cdn.sanity.io"],
})
```
## This machine always passes [#this-machine-always-passes]
Calls to this machine (`localhost`, `127.0.0.1`, `::1`) always go through, even when other hosts
are blocked. That's where your other apps, the Inngest dev server and your databases answer.
If nothing listens on the port, the call gets a 502 that says so:
```text
nothing is listening on 127.0.0.1:8108; start that service or route the host
```
That usually means a service your app needs, such as a search server, isn't running.
## Allow and block by name [#allow-and-block-by-name]
| Option | What it does |
| ----------- | ----------------------------------------------------------------------------------- |
| `allow` | hosts that always pass, even under `block` |
| `blocked` | hosts that always get 502, even under `passthrough`; beats `allow` and this machine |
| `telemetry` | `block` (default) or `passthrough` for analytics hosts |
| `assets` | `passthrough` (default) or `block` for a page's images, fonts and media under block |
## Analytics and telemetry stay out [#analytics-and-telemetry-stay-out]
PostHog, Sentry, Segment, Amplitude, Datadog, hosted trace backends, Google Analytics and the like
are blocked by default. A test run sends them no real data, and your flight records stay free of
their noise. Set `telemetry: "passthrough"` to let them through.
## Live keys are caught [#live-keys-are-caught]
With hosts passing through, a live-looking key for a provider the run doesn't emulate would reach
the real provider. When a [stack](/docs/stack-environment) starts, danwo looks for them:
- `sk_live_…`, OpenAI and Anthropic keys, AWS access keys and similar;
- in an app's environment, or a `.env` file in its folder that the run doesn't override.
Each one is a warning naming the variable and the file, never the value.
| `secrets` (stack option) | Live-looking key found |
| ------------------------ | -------------------------------- |
| `warn` (default) | a warning per key |
| `fail` | the environment refuses to start |
| `ignore` | nothing |
## Next [#next]
How Node apps are routed through the proxy without code changes.
The providers danwo emulates, and how.
# Parallel emulators (/docs/parallel-emulators)
Give each worker its own Android emulator, sized to what this machine can run.
A matrix runs your test many times. On Android each run needs an emulator, so more emulators means
a faster matrix, up to what the machine can hold.
## Size the workers [#size-the-workers]
`androidWorkers` picks a worker count from the machine:
```ts title="danwo.config.ts"
import { androidWorkers } from "@danwo/device"
import { defineConfig } from "@danwo/sdk"
export default defineConfig({
testDir: "tests",
workers: androidWorkers({ avd: "danwo_spike" }),
})
```
An emulator takes its AVD's memory plus about a gigabyte, and two cores. `androidWorkers` allows as
many as half the memory and half the cores fit, at most three by default (`max`). On a machine
already busy past its cores, it allows one.
Give each worker its own emulator with `worker: workerIndex` in `withAndroidDevice`. Every boot is
read-only, so several emulators share one AVD and its snapshots.
## How much faster [#how-much-faster]
The example shop's matrix of 29 runs, on a 12-core, 32 GB Apple silicon machine:
| Emulators | Wall time | Median trial |
| --------- | --------- | ------------ |
| 1 | 160 s | 4.6 s |
| 2 | 103 s | 5.6 s |
| 3 | 95 s | 7.1 s |
Two emulators are the sweet spot there. A third mostly slows each trial down.
## Override it [#override-it]
```sh
npx @danwo/cli matrix tests/buy-credits.danwo.ts --workers 2
```
## In CI [#in-ci]
| Runner | Needs |
| ------ | -------------------------------------------------- |
| Linux | KVM (`/dev/kvm`) and an `x86_64` Google APIs image |
| macOS | Apple silicon runs `arm64-v8a` images |
Create the AVD with `avdmanager`, and save a snapshot so each worker boots in seconds.
## Next [#next]
Run tests and the matrix on every pull request.
Keep a matrix to the trials that matter.
# Playwright Test (/docs/playwright)
Keep your Playwright tests and add danwo's emulators, invariants and fault matrix to them.
Already have a Playwright suite? You don't have to rewrite it. `@danwo/playwright` runs your
Playwright tests behind danwo's proxy and emulators, adds invariants, and runs the fault matrix
over a Playwright test.
## Install it [#install-it]
```sh
npm install -D @danwo/playwright
```
It works with `@playwright/test` 1.60 or newer, so you keep the version you have. The browser is
your own Playwright's, launched behind danwo's proxy. Install it the way you already do, with
`npx playwright install chromium`.
## Point Playwright at your danwo config [#point-playwright-at-your-danwo-config]
```ts title="playwright.config.ts"
import type { DanwoWorkerFixtures } from "@danwo/playwright"
import { defineConfig } from "@playwright/test"
export default defineConfig