Summary#
A UI test is an ordinary [Test]. Ui.Visit opens a route and Ui.Click presses something; Assert.Visible,
Assert.Hidden and Assert.OnPage say what should be true of the screen afterwards. It runs the REAL page — the
same render, the same actions, the same security — so one test covers rendering, interaction and authority together.
A locator is case-sensitive and matches by containment, with an exact match winning over a substring. Two
matches refuse. Scope with within:, which takes either of two things — an entity the test already
holds, or a string naming a container by its label::
Ui.Click("Edit", within: order); // an ENTITY — that order's row, no title or row text to guess
Ui.Click("Save", within: "Billing"); // a STRING — the container whose `label:` reads Billing
Assert.Visible("Shipped", within: order);
Ui.Within("Needs watering") { Ui.Click("Water", within: plant); } // BOTH — when the row is on screen twice⛔ Never rename a button, a card, a heading or a message to make a locator resolve — and you almost never have
to. An exact match already beats a longer title that merely CONTAINS it, so Button("Add") sitting inside
Card("Add an expense") is pressed by Ui.Click("Add") with nothing added to either. When two things genuinely
collide, say WHERE with within:. See [[testing-ui#strict]] — read it BEFORE you name anything, not after a locator
has already refused.
Signature#
Ui.Visit(path); // navigate to a route and wait for it to settle
Ui.Fill(field, value); // type into the field with this label (or placeholder/name)
Ui.Click(label); // press the thing carrying this label — its ACCESSIBLE NAME, not only visible text
Ui.Click(label, within: order); // …in THAT entity's row — an entity the test holds, no title needed
Ui.Click(label, within: "Billing"); // …or a container by its `label:` (a `Card("Billing")` title IS that label)
// ⚑ `within:` is NOT a Ui.Click parameter — EVERY verb and assertion that LOCATES something takes it, on
// exactly these terms: Ui.Fill("Name", "Ada", within: dialog) · Assert.Visible(t, within: card) · Ui.Select
// · Ui.Check/Uncheck · Ui.Upload · Assert.TextIs/Value/Enabled/Items/Before/Cell/… The only ones that do not
// are the ones that locate NOTHING — Ui.Visit, Ui.Viewport, Ui.Press, Ui.Back/Forward, Ui.SignInAs/SignOut
// (and Assert.OnPage / Assert.Dialog, which are page-level by construction).
Ui.Screen(); // PRINT the rendered page into the test output — asserts nothing, never fails
// ⚑ Reach for `Ui.Screen()` instead of asserting a string you know is absent purely to dump the page. It was
// missing from THIS list until 2026-08-27 and an eval run therefore never found it: the verb existed, its
// section was written, and the one place a reader actually reads was the one place it was not.
Ui.Check(field); // put a checkbox ON (never a toggle — order does not matter; it PRESSES it, so
// the action behind it runs and the page re-renders before the next line)
Ui.Select(control, value); // choose in a dropdown/list — the VALUE it holds, never the label it draws
Ui.Viewport(1280); // render at this width — and RESIZE to it, which is itself worth testing
Assert.Items(list, n); // how many items a list is SHOWING (0 is a real answer, not "missing")
Assert.Before(a, b); // a's row is rendered above b's — the assertion a SORT needs
Assert.Cell(row, column, expected); // ONE cell of a table: the row by what it READS, the column by its header
Assert.Dialog(title); // a modal is open, and this is its title (Assert.Visible cannot answer this)
Assert.Checked(field, on); // the read side of Ui.Check
Assert.Expanded(control, open); // a disclosure control says whether it is open
Assert.Selected(option, chosen); // an option says whether it is the chosen one
Assert.Probe(control, field, expected); // a FOREIGN control's own internal facts
Assert.Focused(control); // what the keyboard is on — the other half of Ui.Press
Assert.Enabled(control); // …and the authority half: is this control operable?
Assert.Disabled(control);
Assert.DisabledBecause(control, reason); // refused, AND it says why — the app's own sentence
Ui.Uncheck(field); // …and OFF
Ui.Press(key); // a named key on the focused element: "Enter" · "Escape" · "Tab" · "ArrowDown"
Ui.SignInAs(principal); // BE a declared principal — a real session, no login page
Ui.SignOut(); // end the session; the screen re-checks in place
Ui.AwaitChange(Order); // wait for the live update another session's commit pushes to this page
Ui.Back(); Ui.Forward(); // the browser's history buttons — real history, not a re-visit
Ui.Upload(control, fileName, content); // choose a file — the bytes really are posted
TestClock.Set(instant); // pin the clock — the BROWSER's too, so a countdown is assertable
Assert.Violation(field); // this field is refused — the save was blocked BECAUSE OF IT
Assert.Violation(field, message); // …and it says so (CONTAINS, not word for word)
Assert.Visible(text); // the text is on the screen (CONTAINS — somewhere, in something)
Assert.Hidden(text); // the text is not
Assert.TextIs(text); // something reads EXACTLY this — "0" is not "10", "on time" is not "not on time"
Assert.OnPage(path); // this is the route now showing
Assert.Value(field, v); // what a FIELD holds — not what the page happens to print
Assert.Flow(container, direction); // this container lays its children out "across" or "down"Description#
It drives the real UI, not a model of it. The page is rendered by the same client a browser runs, against the
same server and the same security { } rules. So a test can prove things no API test reaches: that a control is
absent for the wrong principal, that a form's submit landed somewhere, that a gated route sends a visitor to login.
Ui.* acts, Assert.* asks. That split is deliberate. Everything you already know about assertions applies
here, and a UI test reads like the data tests next to it rather than like a browser script.
Waiting is not your job. Each verb settles before returning — a click waits for the action it started to finish, including the server round trip and the re-render that follows. Tests do not sleep, and there is no "flaky, add a wait" step.
⚑ A test owns its world, so there is nothing to race against. It runs against its own branch off the compiled, seeded app, with one client and one principal — nobody else is writing. So a verb's settle is the whole story, and tests neither sleep nor retry.
Say the back-and-forth out loud. When a flow crosses the server and comes back, write the step that reads again rather than assuming the screen caught up on its own. An explicit re-read is a sentence anyone can follow; an invisible wait is a mechanism they have to trust.
To be a signed-in user, SIGN IN. A UI test authenticates by driving the app's own login form — Ui.Visit("/login"),
fill, click — which runs the real authentication pipeline, which is the thing worth testing anyway. A login is three
verbs, so wrap it in an ordinary function when you use it twice; there is no special helper to learn.
Signing in signs the whole test in. Once the app's login has taken, a row read beside the page is read as that person — see #signed-in. So "did the UI really write that?" is an ordinary read, with nothing to declare twice.
⚠ runas does NOT apply to the UI: a UI verb inside one does not compile. runas is for being SOMEBODY ELSE, and
it rebinds the principal on the engine only — the browser is a separate session, still signed in as whoever it was. So
the page would render as THAT person while the test claimed to be this one. The refusal exists because the failure
inverts an assertion rather than merely losing one: Assert.Hidden("someone else's order") would pass against a page
showing exactly the wrong thing — the assertion whose whole job is proving data did not leak, green precisely when it
did. Assert the screen outside the block; reach the UI indirectly, through a helper called from inside it, and the run
refuses it there instead.
What it does not do: pixels. Colours, spacing, fonts and screenshots are not what this asserts — it tests what rendered and how it behaved, not how it looked.
What text does a locator match?#
| Written | Matches |
|---|---|
Ui.Click("Save") · Ui.Fill · Ui.Select · Assert.Enabled — anything that ACTS | the ACCESSIBLE NAME (a control's label:) first, then visible text. An EXACT match wins; otherwise CONTAINS |
Assert.Visible("Water") · Assert.Hidden("Water") | CONTAINS — so it also matches Water (due) and Waterfall |
Assert.TextIs("Water") | EXACT — one element reads precisely this. Whitespace is collapsed on both sides |
within: "Needs watering" | CONTAINS — the container whose label: holds those words, or the row that reads them |
within: order — an ENTITY the test holds | that entity's row, by identity. No title, no row text, no label: needed |
| every one of them | CASE-SENSITIVE. Ui.Click("water") does not press Water |
| two matches | REFUSED, naming both and where they sit. Add within: or Ui.Within(…) |
| the SAME row rendered in TWO containers | within: <row> alone is still two matches. Name the container first — Ui.Within("Due now") { Ui.Click("Water", within: fern); } — scopes COMPOSE, outer to inner |
which verbs take within: | every one that LOCATES something, not Ui.Click alone. Not Ui.Visit/Viewport/Press/Back/Forward/SignInAs/SignOut, which locate nothing |
⛔ A refusal means SAY WHERE, not RENAME. Keep the button labelled Water (due), the empty state reading
Every plant is watered. and the filter labelled Room; scope the locator instead.
| Instead of | Write |
|---|---|
renaming a Water (due) button so Assert.Hidden("Water") passes | Assert.Hidden("Water", within: plant) |
| rewording an empty state to dodge a substring | Assert.TextIs("Every plant is watered.") |
renaming a Room filter that collides with a Room column header | Ui.Click("Room", within: "Filters") |
Card("Needs watering") { foreach (var p in due) { Row { Text(p.Name); Button("Water (due)", onPress: () => Water(p)); } } }
Ui.Click("Water (due)", within: fern); // ✓ the fern's row — an entity the test holds
Assert.Hidden("Water (due)", within: cactus); // ✓ scoped, so another row saying it is not this row's businessExamples#
A page, and a test that opens it and says what should be on the screen:
[Principal] entity User {
[Required, MaxLength(200)] string Email;
security { allow read when IsAuthenticated; }
}
[Page("/welcome")]
[AllowAnonymous]
[Render(CSR)]
component WelcomePage() {
render {
Stack { Text("Welcome"); }
}
}
[TestFixture]
void Seed() {
new User { Email = "alice@test" };
}
[Test(Seed)]
void the_welcome_page_renders_and_is_where_we_landed() {
Ui.Visit("/welcome");
Assert.Visible("Welcome");
Assert.Hidden("Sign out"); // nobody is signed in, so the shell's sign-out is not there
Assert.OnPage("/welcome");
}Signing in, and the helper that falls out of doing it twice — ordinary Osy#, no platform surface involved:
void SignIn(string email, string password) {
Ui.Visit("/login");
Ui.Fill("email", email);
Ui.Fill("password", password);
Ui.Click("Sign in");
}
[Test(Seed)]
void alice_sees_only_her_orders() {
SignIn("alice@acme.test", "hunter2");
Ui.Visit("/orders");
Assert.Visible("Order #1001"); // hers
Assert.Hidden("Order #2002"); // Bob's — genuinely not rendered, not merely hidden
}
[Test(Seed)]
void an_anonymous_visitor_is_sent_to_the_login_page() {
Ui.Visit("/orders");
Assert.OnPage("/login");
}Signing in, versus runas#
Two ways to be somebody in a test, and they are not interchangeable.
runas(Alice) { … } | signing in through the UI | |
|---|---|---|
| what it binds | the ENGINE's acting principal | the BROWSER's session |
| exercises auth | no — it imposes the answer | yes — the real [AuthMethod] pipeline |
| use it for | data, function, workflow and security tests | anything that renders |
with Ui.* | does not compile | the supported way |
A UI verb inside runas is refused, and that is a security property rather than a limitation. The page is
rendered by a client with its own session, so a runas block cannot reach it — the page would render anonymously
while the test claimed to be Alice. Left silent, that inverts an assertion: an anonymous visit to a gated route
renders nothing, so Assert.Hidden("someone else's order") would pass on an empty page, and the assertion whose
whole job is proving data did not leak would be green exactly when the page is broken.
You find this out at COMPILE time — a Ui.* verb or a Assert.Visible / Assert.Hidden / Assert.OnPage written
inside a runas block is an error naming the reason, so the fix is a keystroke rather than a puzzling run. Call a
helper that drives the UI from inside the block and the compiler cannot see it; that one is refused when the verb
actually runs. Both answers say the same thing, and neither is a warning.
So a UI test signs in the way a person does. That costs three verbs, which you wrap in a function the second time you need it — see the example above.
Does the page see what the test just wrote?#
Yes, and you need nothing to make it so. Ui.Visit — and every other Ui.* verb — settles the test's pending
writes before it drives anything, so the page reads them like any other stored row. No [TestFixture], no
UnitOfWork.Commit(), and no assertion in between. A fixture is for SHARING a seed across several tests, not for
making a seed take effect.
[Test]
void a_row_made_here_is_on_the_page() {
new Job { Title = "Skim the ceiling", SortOrder = 5 }; // no fixture, no explicit commit
Ui.Visit("/");
Assert.Visible("Skim the ceiling"); // the page sees it
}⛔ So do not write an assertion whose only job is to force a commit. Assert.Equal(1, Job.Count()); slipped
between the new and the Ui.Visit proves nothing, and it is the shape people arrive at when they are unsure
whether the write has landed. It has landed.
Does a query see what the page has not saved yet?#
That is the OTHER direction — what a query in the test sees of what the PAGE is holding — and it has a real boundary, which the rest of this section is about.
A page holds its edits in a unit of work until something commits them, which is what makes "Save" mean anything. A test sees that boundary exactly as the database does:
- Before the app's save runs, the edits are on the SCREEN and not in the database. A query in the test finds nothing.
- After it runs, the query finds the row.
That is the sharpest test available of a page that must not write early — assert the absence first, then act, then
assert the row. Both halves are ordinary Osy#: Ui.* drives, and the query is the same query any other test writes.
Which read you are looking at decides what it can see, and there are three of them. The test's own query is the easy one — it is a different reader from the page, so it sees the database and nothing of the page's pending work. Inside the page the answer depends on where the read is written:
| the read | where it runs | what it sees |
|---|---|---|
| a query in the test | the test's own reader | the database — never the page's unsaved edits |
a field of a row the page already holds (order.Code), read anywhere — render, a live var, or inside an action | in the browser, over the page's pending edits | the pending edit, before any save |
a live var list query | in the browser | the fetched rows with pending edits applied, plus rows this page created and has not saved |
⚠ A query WRITTEN INSIDE AN ACTION is a fourth case, and it is not one to build on yet. It is a server round
trip taken at that point in the body, so its membership and any Count/Sum it folds are computed from stored
values, not from what the page is holding. Whether that is the intended rule is an open question the platform still
owes an answer — so do not write a test that depends on either answer. Read a live var instead, or commit first
and then query.
[Principal] entity User {
[Required, MaxLength(200)] string Email;
security { allow read when IsAuthenticated; }
}
entity Order {
[Required, MaxLength(50)] string Reference;
security { allow read, create when IsAnonymous || IsAuthenticated; }
}
[Page("/orders/new")]
[AllowAnonymous]
[Render(CSR)]
component NewOrder() {
string reference = "";
// The page's OWN Save — a button a person presses, not anything the test framework supplied.
action Save() {
new Order { Reference = reference };
UnitOfWork.Commit();
}
render {
Stack {
Input(value: reference, placeholder: "Reference");
Pressable(onClick: Save) { Text("Save"); }
}
}
}
[Test]
void editing_does_not_write_until_save() {
Ui.Visit("/orders/new");
Ui.Fill("Reference", "PO-9001");
Assert.Equal(0, Order.Count()); // on screen, not in the database
Ui.Click("Save");
var saved = Order.Single(o => o.Reference == "PO-9001"); // now it is — an ORDINARY query
Assert.Equal("PO-9001", saved.Reference);
}What does the screen actually say? — Ui.Screen()#
Ui.Screen() prints the rendered page — its route and every line of text on it — into the test's output. It asserts
nothing and never fails:
Ui.Click("Add an expense");
Ui.Screen(); // ← what is actually on screen now
Assert.Visible("What was it for"); │ Ui.Screen() — /add
│ Add an expense
│ What was it for
│ How much
✓ the_front_page_leads_to_the_add_screen (1674ms)Log.Information(…) in a test prints the same way, so a value and a screen read alike.
⚑ Reach for it INSTEAD of asserting something you know is absent. A failing Assert.Visible prints the page in
its message, and that made a deliberately-failed assertion the only way to see a screen — an eval run wrote fourteen
versions of one probe test asserting "__dump__" to do it, and recorded the trick as the lesson of the whole run.
It is not a trick any more; it is a verb.
⚠ A page dump is a LEAD, not a measurement. Reading it tells you what to assert; the assertion is still what
proves the behaviour. And a Ui.Screen() left in a committed test is noise for whoever reads the run next — take it
out with the rest of the scaffolding.
Who waits — the VERB, never the assertion#
Every verb finishes what it started before it returns. Ui.Click waits for the action it fired — including a
durable one, across the hand-off gap where the network genuinely goes quiet — and, when that action navigated, for
the page it navigated to: its on mount, its first paint, its live subscriptions. Ui.Visit does the same for
a fresh page, and Ui.Fill for an onInput that reaches the server.
Assertions do not retry. Assert.Visible, Assert.OnPage, Assert.Hidden, Assert.Value — every one of them
reads the screen once, at that moment, and reports what it found. That is deliberate: a retrying assertion turns a
missing wait into a slow pass, so the wait stops being a property of the platform and becomes luck that holds until
the day it does not.
⚠ So a failing assertion is a claim about the screen, not about timing — and swapping one assertion for another cannot fix a race, because none of them wait. If an assertion reports a page you did not expect, the interesting question is what the verb before it did, not which assertion you used.
⚑ The one thing a verb cannot wait for is a change it did not cause — another session's commit, a workflow, a
server function running elsewhere. That is what Ui.AwaitChange is for, and it is the only wait you
ever write by hand.
Waiting for a change somebody ELSE made#
A live var re-reads when the data it queries changes, including when the change came from another session
entirely — the other desk, a server function, a workflow. That push arrives on the server's schedule, so an
assertion written straight after the other session's commit races it, and the driver reads the screen as of its
last verb. Ui.AwaitChange is the sentence that says the back-and-forth out loud:
runas (Priya) { TakeOrder(id); } // the other desk acts, at her own screen
Ui.AwaitChange(Order); // wait for the server's change signal, and the refetch it triggers
Ui.Within("Waiting to be picked up") { Assert.Hidden("Mr Halloran"); }The operand is the entity type — what the server addresses its change frames to — never a row and never a
string. Ui.AwaitChange(Order), not Ui.AwaitChange("Order") and not Ui.AwaitChange(order).
It is explicit on purpose, and it is a real wait rather than a pause: if no signal arrives it fails, naming the socket state, what this page subscribed to, and what signals it did see —
no change signal for 'Order' arrived within 5000ms. Socket: open.
Subscribed to: metadata, Order, User. Signals seen: (none).
Either the server emitted nothing for this commit, or this page never subscribed — a query only subscribes when
it is declared `live`, and only an app-scoped (signed-in) session opens a socket at all.⚠ Each call consumes one signal for that type, so two waits mean two updates rather than one update counted twice. A signal that arrived before the call still counts: the write happens first and the signal chases it.
⚠ You do not need it for a change this page made itself. A click that commits is already settled by the verb
that made it. Ui.AwaitChange is for a commit that happened somewhere else.
Ticking a box, pressing a key, signing out, reading a field#
Beyond Visit / Fill / Click:
Ui.Check(field); Ui.Uncheck(field); // put a checkbox in a state — never a toggle, so order does not matter
Ui.Press("Enter"); // a named key on the focused element: "Enter" · "Escape" · "Tab" · "ArrowDown"
Ui.SignOut(); // end the session; the screen re-checks in place
Assert.Value("Reference", "PO-9001"); // what a FIELD holds — not what the page happens to printAssert.Value asks the field, which is the point of having it: Assert.Visible("PO-9001") would also pass on a page
that merely printed the reference somewhere, and a pre-fill assertion must not accept that.
⛔ Ui.Check PRESSES the control, so its action runs and the page re-renders — and on a filtered list that can
take the row off the screen. Ticking a job on a page whose default view is "what's left" is exactly this: the
tick is right, the filter is right, and the row is correctly gone. So do not read its state afterwards — assert
what you expected:
Ui.Check("Fix the squeaky stair");
Assert.Checked("Fix the squeaky stair", true); // ✗ the row left the view — there is nothing to read
Assert.Hidden("Fix the squeaky stair"); // ✓ that IS the behaviour under test
Assert.Equal(true, Job.Single(j => j.Title == "Fix the squeaky stair").IsDone); // …and the data says the restIf a locator fails on a label your own previous verb removed, the driver says so and names the verb — but the assertion above is the one to write in the first place.
Ui.SignOut asserts in place. Sign out while on a page that needed the session, then assert — without navigating
first. A navigation re-runs the route gate by itself, so a test that moved first would pass against a sign-out that
did nothing but forget a token. What you want to know is that the screen in front of the person changed.
What size is the screen?#
Ui.Viewport(1280); // run at desktop width…
Ui.Visit("/orders");
Assert.Enabled("Name"); // …where the grid is a table with clickable headers
Ui.Viewport(390); // now shrink — and the page must RE-FLOW
Assert.Items("Orders", 3); // the rows are still there, laid out differentlyTests run at 1280 by default — desktop, because that is the layout most of an app's users see. A test that wants the phone says so.
One verb, two uses, because they are the same act. Before a Ui.Visit it chooses the width the page renders at.
After one it RESIZES — and that is worth testing in its own right: a responsive component has to re-flow when the
window changes, and an observer that never fires is a real bug that only a resize catches.
The width is sticky across navigations. A Ui.Visit re-opens the page, and a width that reset each time would
let a test set it, navigate, and silently go back to the default.
⚠ Headless has no layout engine, so every container is reported as viewport-wide. A component asks how much room
IT has (Layout.AtLeast), and a real sidebar's container is narrower than the window — here it is not. So a test
asserts the layout the app picks AT THAT WIDTH, which is the question a responsive test is asking; it is not a
substitute for looking at a real browser once.
How are UI tests run, and why was mine SKIPPED?#
osy test runs your UI tests. There is no separate command and no flag: a [Test] that drives the UI is run by
the same command, in the same run, beside the ones that do not. Each one renders the app through the real client,
against its own throwaway copy of the data — so what it asserts is what a person would see.
A UI test needs a JS runtime (node) to drive that client. On a box without one, a test that drives the UI is SKIPPED with the reason — before its fixture is copied and before a single verb runs:
- drives_the_ui SKIPPED
driving an app's UI runs the real client, and this machine has no `node` on PATH.
Install node 22 or newer and run the tests again.The rest of the suite is unaffected: a data test beside it runs normally, and nothing is disabled on a machine that CAN run the client.
node is the only requirement — there is nothing to install and nothing to configure. The driver ships with the
platform as a single self-contained file, and the client it drives is the one your own running app is already
serving, so a UI test works the same in a fresh install as it does anywhere else.
⚑ The platform works out which tests those are, including through your helpers. Signing in through the UI is three verbs, so it belongs in a helper — and a test calling that helper drives the UI just as much as one spelling the verbs inline. Nothing is declared and nothing is annotated.
Signing in signs the WHOLE test in#
SignIn("ada@acme.test", "hunter2"); // your own helper: visit /login, fill, click
Ui.Visit("/orders/new");
Ui.Fill("Reference", "PO-9001");
Ui.Click("Save");
var saved = Order.Single(o => o.Reference == "PO-9001"); // read AS Ada — no second declaration of who she is
Assert.Equal("ada@acme.test", saved.Owner.Email);From the moment the app's own login takes, the test body is that person: a row read beside the page is read with that principal's permissions. So checking that what the UI did really landed in the database is an ordinary read.
runas(P) is for being SOMEBODY ELSE — proving a row is invisible to another user, or that a second person
cannot close the first person's issue. It still cannot enclose a Ui.* verb or a UI assertion: the browser is a
separate process and is still signed in as whoever it was, so the page would render as THAT person while the test
claims to be this one. Assert the screen outside the block; to drive the UI as someone else, sign in as them.
What order are they in?#
Assert.Before(alba, zeno); // alba's row is rendered above zeno's
Ui.Click("Who"); // sort by that column the other way
Assert.Before(zeno, alba);The assertion a SORT needs. Before rather than a whole-list Order because "now Zeno comes before Alba" is the
sentence a person says, and it does not make a test restate every row it did not care about.
Both operands are values, matched on the row identity the platform stamped as it rendered — never on rendered text, which a cell template can change and which two rows can share. So an entity works as readily as a string.
⚠ A row shown twice is REFUSED, not resolved. The same value rendered in two lists has no single position, so comparing it would answer a question nobody asked — quietly, and differently depending on which list rendered first. A failure shows the order that WAS rendered, which is usually the whole diagnosis.
⛔ A STRING NAMES A ROW BY ALL OF WHAT ITS FIRST CELL READS — not by the part you care about. A row rendering a
name, a category, a number and two buttons is named "Cellared Riesling White 12 ▲ ▼", so the obvious
Assert.Before("Cellared Riesling", …) matches nothing:
Assert.Before("Cellared Riesling", "Corked Merlot"); // ✗ no row reads exactly that
Assert.Before(Bottle.Single(b => b.Name == "Cellared Riesling"), corkedMerlot); // ✓ matched by identityPass the row's own entity. It matches by identity, so it does not move when the row is restyled, gains a column or has a button added — which a string does. Naming the row in full works and is the brittle option.
How many is it showing?#
Assert.Items("Orders", 3);
Assert.Items("Archive", 0); // present and EMPTY — which is a real answerWithout it a list page is assertable only by "some text is on the screen", which passes on a page showing one row and on a page showing a hundred.
It counts the platform's own row stamp, not a shape guessed at from the markup. Every row a foreach renders is
stamped as it is rendered, so the count survives a restyle, an extra wrapper and a change of atom — none of which a
selector-shaped count would. A row that renders several sibling elements still counts once.
⚠ Zero and absent are different answers, and stay different. A list that is on the page and empty counts 0 — asserting that is how you prove a filter excluded everything. A list that is not there at all is a failure naming what the page DOES show. An assertion that conflated them would report a page which failed to render as a successful empty filter, and that is the direction you most want to hear about.
The list is located the way everything else is: by what a person would call it. Give the container a name —
Stack(role: UiRole.List, label: "Orders") — which is the same declaration that makes it navigable to a screen reader
(see accessibility).
Why won't it save?#
Ui.Fill("code", "FAR-TOO-LONG-FOR-THIS");
Ui.Click("Save");
Assert.Violation("Code"); // refused — and the save was blocked because of it
Assert.Violation("Code", "at most 8 characters"); // …and this is what it says
Assert.Empty(Contact); // …and nothing was writtenIt asks the MODEL, not the page. A violation carries the entity and field it is about, so "which field is this message under" is answered by the data rather than by guessing at which text sits nearest which input.
And it requires the message to be ON SCREEN. Both halves, always — because either alone passes for the wrong
reason. The model alone goes green on a form that validates perfectly and shows the person nothing; the screen alone
is Assert.Visible, which passes when those words are anywhere on the page, including under a different field.
The message is matched by CONTAINS, and it is optional. A declared sentence carries the detail that makes it
useful, and a test that had to restate it word for word would be asserting the platform's wording rather than the
app's behaviour. Assert.Violation("Code") alone says "the save was blocked because of this field", which is often
the whole assertion.
⚠ A violation is REVEALED, not merely computed — it appears once the person has left the field or pressed save,
the same moment the browser reveals :user-invalid. A blank field must not be accused before anyone has filled it
in, so assert after the interaction, not before.
⚠ A required field NOBODY HAS TOUCHED is refused by the SERVER, not here. The page judges the edits it has; a
field never typed into is not among them. Catch ValidationException around your commit and show ex.Message — the
demos do — and assert that sentence with Assert.Visible.
A composite [Unique(A, B)] — aim at the pair, not at a member#
A [[entity-constraints#unique|composite unique]] refusal attaches to one field whose name is the members joined
with ", ", in the order they were declared. So [Unique(Expense, Person)] is asserted as:
Assert.Violation("Expense, Person"); // the pair collided
Assert.Violation("Expense, Person", "already on this expense"); // …and this is what it says
Assert.Violation("Share.Expense, Person"); // qualified, when two entities share member namesAssert.Violation("Expense") finds nothing: there is no violation on either member alone. The constraint is
about the combination, so the thing refused is the combination, and it is named as one.
⚠ It exists only AFTER a save the server refused. [Unique] is a question about other rows, which the page
cannot answer, so — unlike MaxLength or Required — nothing is revealed by leaving the field. Press Save first;
the server's refusal is then held on the page beside the right control, exactly like a locally-caught one.
⚠ Assert the MESSAGE only if you declared one. Assert.Violation(field) alone always works. The two-argument
form needs the sentence to be on screen, and an undeclared composite unique is refused with a generated sentence
that names the entity and its columns — schema, so it is masked to "One or more values are invalid." for a caller
with no account. Write [Unique(Expense, Person, "They are already on this expense.")] and that sentence is what
both the person and the assertion get.
What time is it?#
TestClock.Set(new DateTime(2031, 3, 4, 5, 6, 7));
Assert.Visible("2031-03-04"); // the page moved with itTestClock.Set pins the browser too, not just the engine. It always pinned the server's clock; now the page a UI
test is driving reads the same instant. So a countdown, a "3 days left" badge, an "expires in" — anything a render
builds on DateTime.UtcNow — can be proved without a test waiting for real time to pass.
One verb and one instant, deliberately: a test that pinned the server to Tuesday and left the page showing the real Friday would describe a world nobody can reason about.
It sticks across navigation, like the viewport. Pin it once and every later screen is on that clock.
⛔ It is for asserting what the clock RENDERS — never for setting up an input you could not fill. Pinning the
clock so a date field's default becomes the value under test looks like it works, and produces a test that never
touches the thing it claims to. Nothing you wrote is in the assertion: the default is the page's choice, so the
test passes for as long as that choice happens to agree with the pin and goes red on a change no reader will
connect to it. If a control resists driving, that is a defect worth reporting — reach for Ui.Fill on the field,
or set the value through the app's own function and assert what the page shows.
How do I test an upload?#
Ui.Upload("Choose a file", "notes.md", "# Notes");The file is described, not read off a disk. A test runs against a throwaway branch of a compiled app and has no filesystem to point at, so a path would be a promise the language cannot keep. A name and its content are both things the test already knows — and the name carries what most apps actually branch on, the extension.
Only the dialog is simulated. No code can open a file picker. Everything after the pick is the product: the bytes
are posted to the app's file store over the session, and your onUploaded action runs with the stored file — its
FileName, ContentType, Length and the Path the store wrote.
⚠ An upload rides the SESSION, so sign in first. An anonymous page cannot post to the store, which shows up as an upload that never reaches your action.
How do I test the Back button?#
Ui.Visit("/orders");
Ui.Click("PO-1042"); // the app navigates
Ui.Back(); // …and the person presses Back
Assert.OnPage("/orders");
Ui.Forward();It is real history, not a re-visit. Going back to a page the app navigated to is a popstate, and your ROUTER
has to answer it: match the previous route, decide it may be entered, re-render it with no page load. Re-visiting the
path would prove the route renders — which your other tests already prove — and leave the one path a back button
exercises untested. That path is also the one that rots quietly, because nobody presses Back while developing.
Going back across a Ui.Visit is a page load instead, exactly as in a browser. Same verb either way: a person
pressing Back does not know which kind of navigation brought them there, and neither should the test.
⚠ Stepping off either end REFUSES. Standing still would leave the previous screen on display, and every assertion after it would pass against a page the test never navigated to.
One cell of a table#
Assert.Cell("Alba Ruiz", "When", "2026-08-12"); // the row that READS this, under that column header
Assert.Cell(order, "Total", "£240.00"); // …or the row itself, when the test is holding oneName a row the way you would say it out loud — "the Alba Ruiz row" — which is what its FIRST COLUMN reads. That is the column a table uses to identify its rows to anyone looking at it, and it is all a test needs to talk about a row it has only ever seen on screen.
Pass the row VALUE instead when the test already has one (a fixture seeded it, or you just read it back). Both work, and they cannot disagree: an id is not something a cell renders.
The column is named by its HEADER. Never by position, and the cell is never found by the text it renders — that text is the thing being checked, so finding the cell by it would make every assertion either pass or say "not found", and never "reads the wrong thing".
⚠ Two rows reading alike REFUSE, like every other locator here. Narrow with within:, or pass the row itself.
Without it, a table's most interesting assertion is Assert.Visible("£240.00"), which passes when any cell
anywhere on the page says that — including the row you were proving had NOT changed.
It reads the table the way a screen reader does, and that is the design rather than a coincidence: both need the table to declare its rows, headers and cells. So a grid this can address is a grid a person using a reader can navigate column by column, and a grid it cannot is broken for both.
Stack(role: UiRole.Grid) {
Row(role: UiRole.GridRow) { Row(role: UiRole.ColumnHeader) { Text("Who"); } Row(role: UiRole.ColumnHeader) { Text("When"); } }
foreach (var p in people) {
Row(role: UiRole.GridRow) { Row(role: UiRole.GridCell) { Text(p.Name); } Row(role: UiRole.GridCell) { Text(p.Joined); } }
}
}The kit's own DataGrid declares all of this, so a grid built from it needs nothing extra. A hand-built table that
does not is REFUSED, naming the line that fixes it — rather than guessing at a position and asserting a plausible,
wrong cell.
⚠ At a narrow width the kit's grid is a CARD LIST, and a card has no columns. The failure says so, because the
fix is Ui.Viewport(1280) and not a different locator. A test that never says a width runs at the default — see
#viewport.
⚠ A row with fewer cells than the header has columns is reported as the rendering defect it is, not as an empty cell. On screen, every value after the gap sits under the wrong heading.
Choosing in a dropdown#
Ui.Select("Status", OrderStatus.Shipped); // an enum member
Ui.Select("Owner", alice); // an entity the test already holdsThe second operand is the VALUE the field holds, never the label an option draws. Two people can share a name, and a label is a presentation choice a template can change without changing what the field means — so a test written against the label breaks on a restyle and silently picks the wrong row the day two options read alike.
That is not merely a preference: options are drawn by the CALLER's template. A dropdown over your own type renders
whatever you gave it — an avatar and two lines, possibly no text at all — so there may be no label to match on. The
platform stamps each row's identity as it renders it, and Ui.Select matches on that.
It opens the control first if it is closed, because that is what a person does and because a closed dropdown has no options on the page to match against.
⚠ A value with no stable identity is REFUSED. An entity has one (its row), an enum member has one, a string or a number is its own; a plain class instance is not any of those, so there is nothing to match — and falling back to the rendered text would be the silent wrong answer this whole surface exists to avoid.
⚠ An identity nothing on screen carries is a failure naming what WAS offered. Selecting nothing quietly would make a mis-typed test green against a dropdown that never moved.
It searches for the option inside the control it opened, not across the page. That matters because a row
identity is not unique: an enum's members are stamped wherever they render, so a Tabs row over the same enum
carries the same identities as the dropdown's options and would otherwise collide with them.
Typing into a control that is not a plain text box#
Ui.Fill addresses a control by its label, and every labelled input answers to it — including the ones that do
not hold text:
Ui.Fill("How often", "10"); // a NumberField — the value is written as text, stored as an int
Ui.Fill("Price", "12.50"); // a DecimalField
Ui.Fill("Notes", "Water sparingly");A numeric field is still a field: the string is what a person types, and the control parses it exactly as it does when a person types it. There is no separate numeric verb, and none is needed.
A date, time or date-and-time picker is filled the same way, in ISO — and it is worth knowing what that costs, because these controls render no text box at all. Each is a button over a month calendar or a pair of time columns, so the fill opens it, walks the calendar to the right month, and clicks the day, the hour and the minute. The value lands the way a person's click lands it, and the panel is shut again afterwards.
Ui.Fill("Due", "2026-08-23"); // a DatePicker
Ui.Fill("Opens", "09:30"); // a TimePicker — 24-hour, whatever the closed field reads
Ui.Fill("Starts", "2026-08-23 09:30"); // a DateTimePicker — BOTH halves are requiredISO is the spelling because it is the only one that is not a presentation choice: one screen shows the same date
three ways — 23 Aug 2026 on the closed field, 23 August 2026 on the day cell, 2026-08-23 in the app's own text —
and a test written against any of those breaks when a culture changes something the app does not care about.
⚑ AND THE SAME ANXIETY ABOUT NUMBERS HAS THE OPPOSITE ANSWER: assert the rendered text. A value the app
formats itself — total.ToString("C"), "N2", "P0" — renders in the browser byte-identically to the server,
so the string on screen is fixed by the app's own app.DefaultCulture and not by the machine the test runs on.
Assert.Visible("£4,350.00") is stable, and asserting only the underlying Sum(…) leaves the rendered money
untested — which is a real gap, because a page can compute the right number and draw it in the wrong place, or not
at all.
Assert.Visible("£4,350.00"); // en-GB `ToString("C")` — two decimals, and the grouping comma
Assert.Equal(4350m, Invoice.Sum(i => i.Amount)); // …the data too, if you want both⚠ Write the format's OWN spelling. ToString("C") under en-GB is £4,350.00, not £4,350 — the decimals are
part of the currency format. See Culture formatting — ToString(format, culture) for which specifiers run in the browser (N/F/C/P
and the standard date ones) and which take a round trip.
⚠ A TimePicker offers minutes in steps (minuteStep:, 5 by default), so 09:37 is not a value the control can
hold. The refusal lists the minutes it does offer rather than failing somewhere deeper.
Use Ui.Select for a dropdown (above) and Ui.Check/Ui.Uncheck for a checkbox or switch — those hold a
choice rather than text, so filling them has nothing to write. A picker is the other way round: it holds a value,
not one of a list the app supplied, so Ui.Select on one is refused and names Ui.Fill. Each of these refusals
names the verb and the form that do work, so a wrong first guess costs one line rather than an investigation.
Is the box ticked, and what has focus?#
Ui.Check("Email me");
Assert.Checked("Email me", true); // …and read it back
Assert.Focused("Notes"); // what the keyboard is onUi.Check could only ever be written and Ui.Press acts on whatever has focus, so an app could set both and
assert neither — which is exactly the gap that hides a control that stopped reflecting its own state, because every
test that only SETS one still passes.
⚑ Focus is what makes keyboard behaviour testable at all: focus order, focus-on-open, focus-return-when-a-dialog
closes. Ui.Fill focuses the field as typing does, so "fill it, press Enter" behaves the way a person does it.
⚠ A control that does not SAY whether it is checked is REFUSED, not read as off. Its state is a shape — legible
to a person and to nothing else — so answering "unchecked" would be a guess that passes forever. The fix is
role: UiRole.Checkbox + checked: (see accessibility), which is the same line a screen reader needs.
Is the menu open? Is the option selected?#
Assert.Expanded("Project lead", true); // the dropdown says it is open
Assert.Selected(ada, true); // …and this option says it is the chosen one
Assert.Probe("MarkdownEditor", "dirty", true); // …and a foreign control's own internal factsA menu's panel appearing and a chosen row's tint are VISUAL facts — legible to a person looking at them and to
nothing else. expanded: and selected: are the same facts said out loud, for a screen reader and for a test.
Without them the only available check was "some option's text appeared", which measures the app's rendering rather than its statement — and passes just as happily on a control that announces itself closed forever. That is a button that does something invisible.
Assert.Selected names its option by VALUE, exactly as Ui.Select does and for the same reason: a generic
control's options are drawn by its caller's template, so the text is a presentation choice that is ambiguous the
day two options read alike.
⚠ "Does not say" is kept distinct from "says no" throughout. A driver that answered false for a control with no
state at all would make Assert.Expanded(x, false) pass forever on the app whose menu never opens. Both refuse
instead, naming the one line of Osy# that fixes it — role: UiRole.ComboBox + expanded:, role: UiRole.Option + selected:
(see accessibility).
Assert.Probe is the same question asked of a FOREIGN control, whose insides are not on the page to be read at
all. It reads the probe { } block the control's author published — see probe — what a control says about itself for what to declare
and how the four ways it can fail are worded.
Is this control actually refused?#
Assert.Enabled("Save");
Assert.Disabled("Delete organization");
Assert.DisabledBecause("Delete organization", "Only platform admins can delete organizations.");canPress: reflects a declared policy onto a control — disabled when the policy does not hold — and whenDenied:
carries the app's own sentence. These three are the only way to check that end to end, and it is where a wrong
answer costs most: a control that SHOULD be refused and is NOT looks identical on screen to one that is, so an app
can ship the reflection missing entirely and nothing notices.
⚑ Assert the SENTENCE, not just the refusal. A control correctly refused with no explanation is a worse product
than one that says why, and the sentence is the app's own words — the thing the person actually gets.
Assert.DisabledBecause checks the refusal AND the reason together, deliberately: a reason on an operable control
would be a claim about nothing.
A control that is not on the screen is a failure naming what the page DOES show — not "disabled". "There is no Delete button" and "it is enabled" are different findings, and only one of them is about authority.
⚠ A match that is not a control is REFUSED. A locator can land on a div that merely contains the words, and a
div has no operable state — so answering "enabled" for it would be a guess that passes forever. Name the control
itself, or render it as a Pressable/Button/Link.
⚠ A checkbox that does not say whether it is checked is REFUSED, loudly. Ui.Check puts a control INTO a state
rather than toggling it, so it has to read the current one. A control that reports no checked state cannot answer,
and clicking blind would tick an already-unticked box as readily as untick it — so the verb stops and says what is
missing.
The fix is the same one a screen reader needs, and it is one line of Osy#: give the control role: UiRole.Checkbox and
checked: (see accessibility). The kit's own Checkbox and Switch carry them, so this only ever fires on a
control you wrote — which is the point. A UI test is the first non-visual consumer your controls have ever had,
so it finds exactly the gaps a screen reader would, before anyone using one does.
A route that does not exist is a COMPILE error#
Ui.Visit("/ordrs"); // ⛔ no page declares this — named at compile time, with the routes that do exist
Assert.OnPage("/nope"); // ⛔ same check
Ui.Visit("/orders/42"); // ✓ satisfies [Page("/orders/{id}")]A test compiles together with the application it tests, so the compiler holds both halves at once — the routes the app declares and the routes the test asks for. Nothing else in the toolchain is in a position to compare them.
⚑ The runtime failure this replaces is a bad one. A typo'd route navigates fine, the router matches nothing, the page renders nothing, and the test fails three lines later on an assertion about content — pointing at the assertion, which is correct, instead of at the address, which is not.
A route built at runtime (Ui.Visit(where)) is not checked: there is nothing to check it against.
How do I skip the login page?#
Ui.SignInAs(Mia); // the browser is now Mia
Ui.Visit("/managers"); // …and her role decides whether she gets inUi.SignInAs(P) names a declared principal and gives the BROWSER a real session for them. It is
the corollary of runas, not a variant of it: runas rebinds the principal on the ENGINE for a block, this one signs
the browser in.
⚑ It bypasses the login PAGE, never authorization. The ticket is genuine, so [Authorize], a role gate,
Candidates and the entity's own read rules all still apply — the app decides what that person sees, exactly as in
production. That is the whole value, and it is also what stops the verb being a back door.
Why it exists. Driving the app's own signup form can only ever make you ONE person — the account it creates — so "as a support manager" was unwritable, and every test carried three verbs of ceremony to become that one account. Two people with different roles is the ordinary case for any app with roles, and nothing could express it.
Ui.SignInAs(Sam);
Ui.Visit("/managers");
Assert.Hidden("the managers' room"); // Sam holds no Manager grant — refused, by the app's own rule
Ui.SignInAs(Mia); // same browser, different person
Ui.Visit("/managers");
Assert.Visible("the managers' room");It may be the FIRST thing a test does — before any Ui.Visit — in which case the session is carried into the first
screen. Ui.SignOut() ends it, and the next visit does not resurrect it.
⚠ Ui.SignInAs is TEST-ONLY, and the compiler says so. Minting a session for a user id with no credential is
impersonation anywhere else. In an app, sign somebody in with Session.SignIn(ticket) using a ticket an
[AuthMethod] returned.
⚠ Signing in signs the WHOLE test in — the same rule as a page-driven login (see #signed-in): a row read beside the page is read as that person too, so the screen and the database never disagree about who is looking.
How do I assert EXACT text, not "contains"?#
Assert.Visible is CONTAINS: it means "these characters appear somewhere, in something". That is right for prose
and wrong for a VALUE — it cannot tell a cell reading 0 from one reading 10, nor a status of "on time" from one
reading "not on time".
Assert.Visible("0"); // ✓ …and also passes on a page whose only number is 10
Assert.TextIs("0"); // something reads exactly "0"
Assert.TextIs("0", within: card); // …in THIS card, which is nearly always what you meantWhitespace is collapsed on both sides, so a phrase broken across lines by the layout still matches. within:
applies, and an exact read usually wants it: page-wide, Assert.TextIs asks whether ANY element reads that, which
is a much weaker claim than "this row says 0".
⚑ The failure tells you WHICH kind it is, and that is the point. Two very different things look identical through a CONTAINS lookup:
| what happened | what Assert.TextIs says | where the fix is |
|---|---|---|
| nothing says it | nothing reads exactly '…', with what the page does show | the data, the filter, the route |
| the words are there, in SEPARATE elements | … is on the screen, but SPLIT ACROSS SEVERAL ELEMENTS | the markup, or assert one part |
The second is worth its own sentence because it is invisible in the source you are reading. Row { Text("0"); Text("breached"); } renders as one phrase to a person and as two elements to everything else — so
Assert.Visible("0 breached") matches nothing while both words sit plainly on screen, and the stray breached
breaks an Assert.Hidden("breached") somewhere else at the same time. One markup detail, two misleading failures.
Naming what to click — never rename the page for a test#
Read this while you are WRITING the page, not after a locator has refused. Most of the ambiguity people brace for does not exist: an exact match beats a longer label that merely contains it, so the button, the card and the heading all keep the words a person should read. The rest of this section is what to do on the day two things genuinely collide — and it is never a rename.
A locator that matches two different things REFUSES. It does not pick one.
```osy title="two matches refuse — the two things within: takes" syntax
Ui.Click("Edit"); // ⛔ every row has an Edit — refused, naming what it found
Ui.Click("Edit", within: order); // ✓ that order's row
Ui.Click("Save", within: "Billing"); // ✓ the container with that label:
Assert.Enabled("Delete", within: row);
⚑ **Why refusing beats guessing.** Every ancestor of a match also contains its text, so a locator has to prefer the
innermost — and once it is choosing by depth, two genuinely different matches are decided by *how deeply the app
nests them*. That is right by luck and silently wrong the day somebody adds a wrapper. An exact match still wins over
a substring, so adding a "Save changes" button does not make an existing `Click("Save")` ambiguous.
⚑ **THE CASE PEOPLE PRE-EMPTIVELY REWRITE THEIR PAGE OVER, WRITTEN OUT AND COMPILED.** A `Button("Add")` inside a
`Card("Add an expense")` is the one that looks alarming: the card title contains the word the test presses. It
resolves, because the button is an EXACT match and the title is only a containing one — so the button stays "Add",
which is what a person should read on it, and the test stays `Ui.Click("Add")`:
```osy title="a Button `Add` inside a Card `Add an expense` — the exact match wins" test app=testing-ui-strict
using Osyrin.Ui;
entity Expense {
[Required, MaxLength(200)] string Description;
security { allow read, create when IsAnonymous || IsAuthenticated; }
}
[Page("/expenses")]
[AllowAnonymous]
[Render(CSR)]
component ExpensesPage() {
string draft = "";
void Add() {
new Expense { Description = draft };
draft = "";
}
render {
Stack {
Card("Add an expense") { // the TITLE contains the word "Add"…
Field("Description", value: draft);
Button("Add", onPress: Add); // …and the BUTTON is still just "Add"
}
Card("Expenses") {
foreach (var e in Expense.OrderBy(x => x.Description)) { Text(e.Description); }
}
}
}
}
[Test]
void the_button_keeps_the_name_a_person_should_read() {
Ui.Visit("/expenses");
Assert.Visible("Add an expense"); // the containing title really is on the screen
Ui.Fill("Description", "Taxi");
Ui.Click("Add"); // ✓ the exact match wins — no rename, no `within:` needed
Assert.Visible("Taxi");
}So do not pre-emptively rename that button to Add expense, and do not redesign the card to get the word out of its
title. Neither buys anything the platform has not already given you, and both cost the page a word a person was
meant to read. If you are unsure, write the page the way it should READ and let the test tell you — a genuine
collision refuses loudly, by name, and the fix is one within:. Nothing about it is silent, so there is nothing to
insure against in advance.
within: names a container by its label:, or a ROW BY WHAT IT IS — an entity, an enum member, a string — the
same way Ui.Select and Assert.Before name theirs. So within: order finds that order's row without the test
knowing what the row happens to render, and it keeps working when the template changes.
⚑ A TITLED CONTROL ALREADY HAS THAT label: — you do not add one. Card("Needs watering") passes its title
down as the surface's label:, so the card is addressable as within: "Needs watering" with nothing extra written.
That is the usual way a page gets its scopes: give the two lists that both render a Water button a Card title
each, and every ambiguous locator in the file resolves.
Card("Needs watering") { foreach (var p in due) { Row { Text(p.Name); Button("Water", onPress: () => Water(p)); } } }
Card("Your plants") { foreach (var p in all) { Row { Text(p.Name); Button("Water", onPress: () => Water(p)); } } }
Ui.Click("Water", within: "Needs watering"); // ✓ the due list's button, not the other one⛔ DO NOT REWORD THE PAGE TO MAKE A TEST PASS. A locator that refuses, or an Assert.Hidden that matches text
you did not mean, is asking you to say WHERE — not asking the app to be called something else. Renaming a card,
a button or a heading to dodge a match changes what a person reads to satisfy a test, and the collision comes back
the next time two things legitimately share a word.
⚑ AND BEFORE YOU CHANGE ANYTHING, LOOK AT THE PAGE — Ui.Screen() ([[testing-ui#screen]]). A refused or missed
locator is exactly the moment the screen is worth printing: it asserts nothing, never fails, and shows you the labels
and the containers that are actually there. Almost every rewrite in this section is a guess about what the page
renders, made by someone who could have read it in one line.
⚑ A STRING scope matches a row that CONTAINS it — the same containment Assert.Visible uses, not an exact match
on the row's whole text. within: "Mistborn" finds the row that mentions Mistborn; the row also renders an author, a
badge and three buttons, and none of that has to be spelled out. (Ambiguity still refuses: two rows both mentioning
it is a refusal, not a guess.)
⚠ AND WHEN ONE ITEM IS LISTED TWICE, NAMING IT HARDER CANNOT HELP — a page showing the same entity in two lists (a roster and an editor) gives a row scope two matches of the SAME row, because a row is matched by its IDENTITY before its text. There is nothing unique left to name. Say which LIST instead, and let the row resolve inside it:
Ui.Within("Scores") { // the list, by its `label:`
Assert.Hidden("Grace Hopper", within: "Ada Lovelace"); // …then Ada's row, inside it
}This is why a container worth scoping to is worth giving a label:.
How do I click an icon button with no text?#
Every locator matches the ACCESSIBLE NAME, and falls back to visible text — in that order. So an IconButton
that renders a glyph is found by the label: it declares, not by the glyph:
IconButton(onPress: Remove, label: "Delete") { Icon(Icons.Trash); } // renders an icon…
Ui.Click("Delete"); // …and is pressed by its labelThis is why label: is worth setting on anything without words in it: it is the same string a screen reader
announces and the same string a test presses, so an unnamed icon button is unreachable to both.
Scoping several calls at once — Ui.Within#
Inside a dialog you do not need this at all — a modal already scopes every locator to itself. See [[testing-ui#dialogs]]. Reaching for
Ui.Within("<the dialog's title>")there is the common wrong turn: the dialog IS the container with that name, so there is no inner one to find and the call is refused.
To name a ROW, pass the row — not a word it renders. A test that already holds the entity holds the only unambiguous handle there is, and it needs nothing on screen:
var fern = Plant.Single(p => p.Name == "Fern");
Ui.Click("Water (due)", within: fern); // that row's button
Assert.Hidden("Water (due)", within: cactus); // another row saying it is not this row's business⛔ Never change what the app RENDERS to make a locator unambiguous. Renaming a Water (due) button so
Assert.Hidden("Water") passes, or collapsing two lists into one so a substring stops matching twice, makes the TEST
a reason to keep a design choice — and the scoped form above already answers it. Scoping by the row is matched on
IDENTITY, so it keeps working when the row's text changes, and another row reading alike cannot steal it.
⚠ If the SAME row is rendered TWICE — a "Due now" card and an "All plants" card below it — its identity is on screen twice, and naming the row alone is ambiguous. That is not a reason to go back to text. Name the container first and the row inside it; scopes COMPOSE, outer to inner:
Ui.Within("Due now") {
Ui.Click("Water", within: fern); // that card's copy of that row, and nothing else
}⚑ This is the case a two-card page always has, so reach for it before reaching for text. Naming only the card works until a second row is due.
When more than one call belongs to the same container, name it once:
Ui.Within("Edit book") {
Ui.Fill("Title", "Mistborn");
Ui.Fill("Author", "Brandon Sanderson");
Ui.Click("Save changes");
}This is sugar for the within: argument above — it adds one to every locator in the block and changes nothing
else, so there is no second scoping mechanism to learn. Three rules, and each is the obvious one:
- A
within:written at the call site is resolved INSIDE the block's container. The nearer scope still decides what a locator sees — it is just looked for within the one you already named, rather than starting again from the whole page.Ui.Within("Scores") { Ui.Click("Edit", within: "Ada Lovelace"); }means Ada's row, in the Scores list. - A verb that locates nothing is left alone —
Ui.Visit,Ui.Press,Ui.Viewport,Ui.Back/Forward,Ui.SignInAs/SignOut. A navigation inside the block is still just a navigation. - It nests, by the same rule: an inner block narrows inside the outer one, so the scopes COMPOSE into a path rather than replacing one another. Three nested blocks are three segments, outer first.
It takes whatever within: takes — a container's label:, or a row named by what it is.
A scope that names nothing fails at the scope, not later as a missing button — "there is no Edit here" would send you to look at the wrong thing entirely.
within: narrows what a test can SEE, not only what it can click. Assert.Visible, Assert.Hidden and
Assert.TextIs all read inside the scope:
```osy title="within: narrows what a test can SEE, not only what it can click" syntax
Assert.Hidden("on time", within: breachedCard); // this card must not say it — other cards may
⚑ **`Assert.Hidden` is the one that needs this most**, and the one that is useless without it. A scoped `Visible`
usually passes either way, because the text is normally inside the row you named as well as on the page. `Hidden`
inverts that: page-wide it goes red exactly when some OTHER row says the word, which on a list is the normal state
of the world.
### While a dialog is open {#dialogs}
**A modal makes the screen behind it inert, and the verbs follow that.** While a dialog is open, every locator —
`Ui.Click`, `Ui.Fill`, `Ui.Select`, `Assert.Enabled` — reaches only INSIDE the dialog. This needs no extra
ceremony and there is no "click in the dialog" verb: it is simply what a modal means.
```osy syntax
Ui.Click("Delete"); // the page's button — opens the confirm
Assert.Dialog("Delete this order?"); // it opened, and it is the RIGHT one
Ui.Click("Delete"); // the CONFIRM's button; the page's is behind a scrim
Assert.Visible("Deleted");⚑ This is why the two lines above are not ambiguous. A confirm dialog repeats the word on the button that opened it — "Delete" → Delete this order? → "Delete" — so a page-wide locator has two equally good matches and must break the tie somehow. Breaking it by position in the page is how a test comes to press a control the user could not have pressed, leave the dialog open, and still go green.
A dialog opened from a dialog scopes to the innermost one, for the same reason: that is the only one in front of the person. Answering it gives the previous scope back.
Assert.Dialog(title) is how you ask whether a modal is up, and which. Assert.Visible(title) cannot answer
it: it means "somewhere on screen", so it passes on a page that merely MENTIONS those words and on one whose dialog
never opened. It stays page-wide deliberately — narrowing it would make a failing test hide the page you need to
see — so the two verbs answer different questions and both are worth having.
⚠ A dialog that is open and names itself to nobody is REFUSED, not failed. It is on screen and working; it
simply cannot be identified by its title, so reporting "wrong title" would send you to fix a title that is correct.
The refusal names the fix, and it is one line — give the panel role: UiRole.Dialog and label: <its title> (see
accessibility). The kit's own Dialog already carries both, so this only ever fires on a panel you drew
yourself. A UI test is the first non-visual consumer your dialogs have ever had.
Verbs that are deliberately absent#
Everything above this line exists. What follows is the short list of things a reader reasonably expects to find here and will not — each with what to reach for instead.
⚠ There is no Ui.ClickHeader, because Ui.Click already is one. A column header is something a person
clicks, so it is clicked by the same verb as everything else — Ui.Click("Total", within: "Reports") re-sorts the
grid under that header, and #order is the assertion that proves the rows actually moved. A dedicated verb
would name a second way to do one thing.
⚠ There is deliberately no Ui.Hover. Hover in Osy# is a STYLE — Hover { } in a variants block — and there
is no hover EVENT to bind, so nothing an app can write happens on hover. A verb for it would drive nothing and
assert nothing, which is the one thing this surface refuses to ship. If a hover-triggered surface is ever built it
has to answer to focus as well (a tooltip that only appears on hover is unreachable by keyboard and by touch), and
Assert.Focused already covers that half.
⚠ This section was headed "Not built yet", and that heading cost more than the gap it described. It kept
listing verbs as unbuilt long after they had shipped, and a single unbuilt fence made osy docs testing-ui open
with a page-wide warning that "a surface does not exist yet" — the first line a reader who came here to learn UI
testing ever saw. Assert.Enabled / Assert.Disabled are real, documented under #authority. So is
Assert.Dialog, under #dialogs, and the cell assertion, which shipped as
Assert.Cell(row, column, expected) under #cell — a row is addressed by what it IS, so it needs no grid
operand. Assert.RowCount is Assert.Items.
What this page CANNOT ask — geometry#
⛔ Every assertion on this page is about TEXT or STATE, and all of them pass on a screen that is visually broken.
Assert.Visible("Confirm") holds for a button with a banner drawn over it; Assert.Enabled("Save") holds for a
button laid out past the edge of its own card; Assert.Visible("Quarterly revenue report") holds for a chip
rendering Quarterly rev…, because the DOM carries the whole string whether the box shows it or not.
That is not a gap in the locators — it is what the renderer can see. osy test renders in happy-dom, which has no
font engine and no compositor, and it is fast enough to run on every change precisely because of that.
The geometric claims live next door in Layout assertions — is it actually usable on screen? — Assert.Clickable, Assert.FitsOn,
Assert.Above, Assert.Inside and the rest — and are checked by osy test --pixels, which drives a real browser.
Under plain osy test they report themselves NOT CHECKED, never green. Ui.Shot("label") photographs the page
beside them.
See also#
- Layout assertions — is it actually usable on screen? — the geometric claims this page cannot make, and
osy test --pixels - Assert — the rest of the assertions, which work here unchanged
- runas — running a test as a principal, which is what makes the screen theirs
- [Test] / [TestFixture] —
[Test]and[TestFixture] - runas — the other way to be a principal, and why it does not apply here