Skip to content

Browser testing: interacting with a UI

Browser steps live in the same .tflw file as API steps — a login → seed-via-API → drive-UI → assert-backend-state test stays one readable file. playwright is an optional peer, dynamically imported only once a suite actually runs a browser step — install it yourself, then let tflw download the browser binaries for it:

Terminal window
npm install -D playwright
npx tflw install-browsers
open "/orders/{orderId}" # relative to the active env's `web` base URL
click button "Add to cart"
double click button "Row"
right click button "Row"
fill field "Email" with {email}
select "Widget" from field "Size"
tick field "Accept terms" # ticks a checkbox/radio
untick field "Accept terms"
hover button "Menu"
press "Enter" # page-level
press "Enter" on field "Search" # scoped to one locator
scroll to button "Load more" # scrolls the locator into view
accept dialog # arms a handler for the *next* native dialog
click button "Delete" # (the browser otherwise auto-dismisses silently — a real
# no-op trap for a confirm()-guarded action)
dismiss dialog

Every interaction step polls up to timeout browser (default 30s, falling back to timeout step when only that is set) for its locator to resolve — sleep doesn't exist, only auto-waiting/auto-retrying.

An arming is consumed by exactly one dialog, in the order you wrote them. So an action that raises two dialogs takes two armings:

accept dialog
accept dialog
click button "Delete all"

That matters because there is nowhere else to put the second one — both dialogs come from a single click, so no step can sit between them.

Assert what the dialog actually was:

accept dialog
click button "Delete"
expect dialog message equals "Really delete?"
expect dialog type equals "confirm"

dialog type is one of alert, confirm, prompt, beforeunload. Both subjects read the last dialog of the current test, and reading either before any dialog has appeared fails the test saying so.

A prompt asks for text, so the arming can carry the answer:

accept dialog with "Blue"
click button "Set favourite colour"
expect text "Favourite: Blue" is visible

The answer interpolates like any other value, so a captured or generated one works:

let colour = "Blue"
accept dialog with {colour}
click button "Set favourite colour"

accept dialog on its own answers with the empty string, exactly as it always has.

Only prompt has anywhere to put an answer. Send one to an alert or a confirm and the run says so and carries on:

Terminal window
⚠ warning[TF080]: `accept dialog with "Blue"` answered a `confirm`, which takes no text —
the text was ignored and the dialog was accepted as if no `with` had been written

It is a warning, not a failure: the dialog is still accepted and the test's verdict is untouched. A page that raises a prompt or a confirm depending on its state is a real thing to test, so tflw will not refuse the step — but it will not let the text go missing quietly either, which is what happens underneath if nobody says anything.

dismiss dialog
expect text "kept" is visible

If no dialog appears before the test ends, that arming did nothing — the same "the line could be deleted and the test would prove the same thing" trap as the alert above, one step earlier. tflw says so, on the passing run:

Terminal window
⚠ warning[TF079]: `dismiss dialog` armed the next native dialog and no dialog was raised before
the test ended — the arming did nothing, and deleting the line would not change what this test
proves

One warning per unused arming, at the line that wrote it. It appears only on a test that passed: when a test fails, an arming after that point is usually stranded by the failure rather than dead, and pointing at it would send you to the wrong line.

Two dialogs and one arming is not this: the extra dialog takes the dismiss default and nothing is left over. And beforeunload is the honest exception — browsers raise it only after a real user gesture, so a headless run may correctly never see one.

fill form
| "Name" | unique("user") |
| "Email" | unique email |
| "Age" | random number 18 to 99 |

Each row's left cell is a quoted field name — same resolution as a bare fill field — and each row executes and reports as its own sub-step. No auto-verify; audit with an explicit check/ expect line if you need one.

The noun picks the resolution strategy — cascading isn't the problem, cascading invisibly would be:

  • button "…" / text "…" / list "…" — single strategy (role+name / visible text / role="list"+name).
  • field "…" — a closed 3-step cascade: label → placeholder → role (textbox). A below-tier-1 resolution is annotated right in the CLI/report line (field "Search" (resolved via placeholder)), never silently accepted.
  • Escapes: css "…", xpath "…" — greppable when nothing else fits, and worth naming once with element when more than one test needs them.

Ambiguity is always a hard error — more than one match never silently picks the first. The error lists up to 5 matched candidates' visible text and suggests within <container> or a more specific name:

within list "Cart items" # scopes every nested step's locator resolution to inside
click button "Remove" # this container, block form, same indentation as any
# other construct — no brace syntax

Cold-start diagnosis. A persistently-unresolved semantic locator (button/field/text/ list — css/xpath are skipped, nothing semantic to fuzzy-match) scans the live DOM for elements of the right shape and appends up to 5 ranked, ready-to-paste suggestions to the "no element found" error — a typo like click button "Add to Crat" surfaces button "Add to Cart". This only changes what the failure message suggests, never which element a step acts on.

A selector the page owns — an id, a data-test attribute — is the part of a test that breaks when the markup changes. Write it once, give it a name, and use the name everywhere a locator goes:

element cartCount = css "[data-test=cart-count]"
element checkout = button "Checkout"
test "the badge counts what was added"
open "/"
click button "Add to cart"
within cartCount
expect text "1" is visible
click checkout

A name is one word, and it can be imported like an action: put the declarations in shared/elements.tflw and import it. The report shows the locator the name stands for (click button "Checkout"), so a failure reads the same as if you had written it inline. A name nothing declares is TF089.

tflw check finds the selectors worth naming for you. A css or xpath string written in two or more files is offered as a reuse hint, and tflw refactor apply <id> writes the element line into shared/elements.tflw and replaces every site with the name.

For a verified (not best-guess) locator while you're still writing a test, point tflw pick at a real running page and click the element you want — see Running & debugging tests.

Locators (button "…", field "…", text "…", list "…", css "…", xpath "…") are assertion subjects too, using the state/value/count matchers from the Matchers reference:

expect button "Pay" is enabled
expect field "Email" has value "a@b.c"
expect list "Cart items" has count 3
expect button "Loading spinner" is not visible

is is an optional copula that carries no meaning — is not visible, not is visible, is visible and not visible all parse, and the docs use is not visible because it reads as English. Negation is not in front of any matcher, exactly as it is for API subjects.

These auto-retry to timeout expect (default 5s) — a UI expect is tflw's own retry loop, not a thin Playwright wrapper. has count is the one matcher meaningful against more than one element; every other matcher still hard-errors on ambiguity. "Zero elements" is itself a legitimate, non-erroring state for is hidden, is not visible and has count 0.

Full reference: SPEC.md §9.

pause 500ms waits a fixed duration and does nothing else. It is the blunt instrument, and it is here for completeness rather than as a recommendation: a wait that exists because the page is slow belongs in wait until, which waits for the condition and stops as soon as it holds. A fixed pause that is long enough is slow on every run, and one that is short enough is flaky on a bad one.

Where pause earns its place is as think-time — a deliberate gap between steps, standing in for a person reading the screen, which is why it also turns up in load testing. There the delay is the thing being modelled, so a fixed duration is exactly right.