Core concepts
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.
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
| 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) |
Locators never call a model. Agent steps call one the first time, then replay from a recording.
Find an element with a locator
screen finds elements by what a user sees. Prefer these, in this order:
import { test } from "@danwo/sdk"
test("signs in", async ({ app, screen }) => {
await app.open("/login")
await screen.getByLabel("Email").fill("[email protected]")
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
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 |
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 |
When an action keeps waiting
Component libraries often draw a checkbox or radio over an invisible <input>. 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.
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
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 |
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:
locator.click: getByRole("listitem").filter(hasText: "Kiwi") matched nothing within 10000 ms; by step: getByRole("listitem") 3, .filter(hasText: "Kiwi") 0Match 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:
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.