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:
npm install -D playwrightnpx tflw install-browsersInteraction steps
Section titled “Interaction steps”open "/orders/{orderId}" # relative to the active env's `web` base URLclick 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/radiountick field "Accept terms"hover button "Menu"press "Enter" # page-levelpress "Enter" on field "Search" # scoped to one locatorscroll to button "Load more" # scrolls the locator into view
accept dialog # arms a handler for the *next* native dialogclick button "Delete" # (the browser otherwise auto-dismisses silently — a real # no-op trap for a confirm()-guarded action)dismiss dialogEvery 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.
Native dialogs
Section titled “Native dialogs”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 dialogaccept dialogclick 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 dialogclick 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.
Answering a prompt
Section titled “Answering a prompt”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 visibleThe 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:
⚠ 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 writtenIt 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.
An arming nothing ever uses
Section titled “An arming nothing ever uses”dismiss dialogexpect text "kept" is visibleIf 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:
⚠ 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 provesOne 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
Section titled “fill form”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 locator model
Section titled “The locator model”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 withelementwhen 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 syntaxCold-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.
Naming a locator once — element
Section titled “Naming a locator once — element”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 checkoutA 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.
Waiting & UI-state assertions
Section titled “Waiting & UI-state assertions”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 enabledexpect field "Email" has value "a@b.c"expect list "Cart items" has count 3expect button "Loading spinner" is not visibleis 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.
Waiting on purpose — pause
Section titled “Waiting on purpose — pause”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.