Skip to main content
A predicate declares what should be true, as { kind, ...fields }. Reticle has thirteen kinds: signal, state, net, route, element, text, console, animation, settled, compare, and the combinators allOf, anyOf and not. Which one you pick decides whether a green means anything. It is the argument to until on reticle_act_and_wait, and to predicate on reticle_assert and reticle_assert { action: "wait" }. Every predicate is { kind, ...fields }.

Not all predicates prove the same thing

Reticle grades them, and it will tell you when you have chosen a weak one. This is a real response from a text assertion:
Take the advice. element and text are convenient and weak; signal, state and net are what make a green trustworthy. The verdict reports the grade it proved, strongest first: signal, net, state, then presence.

The leaves

element

query accepts role, name, text, label, placeholder, testid, alt, value, by, component, source, scope, self, attrs, matchCase. Written flat on the predicate instead of under query, these fields are moved into query for you. state is one of visible, hidden, enabled, disabled, checked, expanded, pressed, focused, inViewport, present. value is the one worth knowing about. { "role": "textbox", "name": "Amount" } collapses to “a textbox called Amount exists”, which passes against an empty field; adding value is what makes it an assertion about what the user will actually send. by selects the locator strategy explicitly, component and source match React component identity and source location, and self restricts a scoped match to the scope element itself rather than its descendants. attrs is a list of attribute names, such as ["href", "data-status"]. It projects those attributes onto each match so you can read them back, and it does not filter. ["data-status=complete"] is refused: it would otherwise look up an attribute literally named data-status=complete, find none, and leave the predicate resting on the locator alone, which is a green for any value the element happens to hold. To READ the values, call reticle_look { action: "find" } with attrs and look at the attrs map on each match. To ASSERT one: a form field’s value goes in the query as value, while a state expressed as a data attribute is better asserted as the consequence it stands for, such as a signal, a net call, or the text the user actually sees.
Add "absent": true to assert something is gone. The predicate for a removal, a dismissed toast, or a regression check.

text

contains is the field the published schema declares. value, text and query are accepted as aliases and rewritten to it. The match is case-sensitive. ignoreCase: true relaxes that. Curly quotes (‘ ’ “ ”), the en dash and the em dash match their plain forms. So It's saved - done matches a page that shows It’s saved – done. Text that Reticle reports back keeps the page’s own characters. absent: true asserts the text is not present, and visible: true requires it to be on screen rather than merely in the DOM. text reads the text that the page renders. It does not read what is typed into an <input> or a <textarea>, because that is the field’s value, not text on the page. value on a text predicate is only an alias for contains, so it also reads rendered text. To assert what a field holds, use an element predicate with value:
Use scope to restrict the search to a CSS selector or element ref. If Reticle reports that the text is split across the scope’s children, retry its generated predicate with "self": true; that checks the scope root’s combined subtree text while still enforcing contains. satisfies asserts a PROPERTY of the rendered text instead of its exact bytes: the same shape and the same properties state accepts, applied where the user actually reads the value:
That is the assertion a generated feature can pass twice. contains demands bytes a model’s output does not repeat, so it either fails on correct output or gets weakened to a word so common it proves nothing; “the box rendered and there is something in it” is the failure the feature actually has. Two rules, both refusals rather than guesses:
  • One of contains or satisfies is required. With neither, the predicate matches any element with any text and cannot fail.
  • satisfies on its own needs a scope (the element whose text is read, with self: true for the root’s own subtree). Without one the locator is every element on the page, and the property would run against whichever matched first.
Both may be given, and then both must hold. satisfies narrows, it never excuses.

net

The one that catches the expensive bug:
Exactly one. Double-submit fails at two, and nothing on screen would have told you. Two fields read the RESPONSE BODY, so a request that succeeded with the wrong number is still a failure:
bodyContains is a substring test over the response body. It is deliberately not a JSON path: the string above is the whole assertion, it needs no schema, and it behaves the same on JSON, form encoding and plain text.
bodyMatches is a field match, keyed like signal.dataMatches and taking the same operators (*, $gte, $contains). A key can be a dotted path into a wrapped answer: { "data.status": "completed" }, { "items.0.id": "a1" }, { "items.length": 3 }. A nested object written out as a value is compared whole, so use the path to match one field inside it. Reach for it whenever the value is an enum, because that is the case a substring cannot decide: bodyContains: "completed" passes on a body carrying "completedAt": null, where the needle matched the KEY and the job was still queued. completed/completedAt, success/successRate, sent/unsent all have that shape. Two more read the REQUEST body, what the UI sent: requestBodyContains (a substring) and requestBodyMatches (a field match, same operators). They are separate fields so neither can be satisfied by the wrong half of the exchange. All four require body capture, reticle.connect({ captureNetworkBodies: true }), and say so when the body was never recorded rather than reporting an ordinary mismatch. Some endpoints read over POST: a GraphQL query, a tRPC batch, a query bus, a Server Action that loads a list. When one runs twice in an action window, which React StrictMode does in development, the second call looks like a double submit and the verdict comes back unknown with duplicate-request. Declare the endpoint a read on its clause:
repeatable: true keeps that endpoint out of duplicate-request for this verdict only. The request is still waited for and still counted, so count keeps its meaning. It needs a non-empty urlContains, and a clause without one is refused, because it would excuse every request in the window. When the clause names a method, only calls with that method are excused: a repeatable GET never excuses a POST to the same URL. Leave it off for an endpoint that writes: there, the second call is the bug.

route

pathname matches the path exactly. contains matches anywhere in the whole route, path, query and fragment together. path is accepted as an alias for pathname, and url, urlContains and pathContains as aliases for contains, but the canonical spellings are the ones above. A passing route verdict quotes the transition itself:
route reads the last route change in the window. When there was none, it reads the session’s current URL instead, and the evidence says decidedBy: "current-route". Only when neither exists does it fail, and the response says so: expected: "a route change to /", assertion: "route.changed".

console

“The flow completed and logged nothing.” Worth attaching to most actions. Plenty of features work while quietly throwing. contains narrows it to one message: with absent: true it asserts THIS message did not appear, rather than that nothing was logged at all. textContains is accepted as an alias.

signal

The app emitted a named signal via reticle.signal(). dataMatches matches field by field, a key can be a dotted path ("order.total"), and * means “present, any value”. Without name, the check matches any signal from the app. It ignores Reticle’s own signals, such as reticle:busy. To match one of those, name it. count works here exactly as it does on net:
Exactly once. A handler wired twice fires the signal twice and leaves the store in the right shape either way, so every state-only check stays green. count is exact on signal and on net. Leave it out to ask for at least one. count: 0 means never, and Reticle holds it like every other absence: for at least one second, and until the page goes quiet. A signal or a request that comes late still fails it. Nothing outranks this. A 200 proves the server was reachable; a rendered row proves React ran. A signal is the application itself saying the thing succeeded. See instrumentation for how to emit them.

state

Walks a dot-path, with numeric array indices. equals takes a literal, or an operator pattern: Omit equals entirely to assert presence. A real $length check against this fixture:
The because line names the grade (“at state grade”). The evidence shows each row as a size marker, not as forty objects.

satisfies: asserting a property instead of exact bytes

equals cannot express output that is right differently every run. A generated summary, a classification, a computed total: each is correct without being predictable, and an assertion that demands exact bytes either fails on correct output or gets weakened until it proves nothing. satisfies is the other half of state, and it stays decidable here with no model and no network.
equals and satisfies may both be given, and then both must hold. A failing check always says what it saw, because an assertion that reports only false sends you back for another call to find out why, and that round trip is most of what a verdict costs.

The relative properties: comparing with before

The properties above read the present tense. Four read the past one. They compare this value with the reading taken before the action, which is the window a verdict is already scoped to:
increased and decreased take an optional by, in the protocol’s own comparison vocabulary: op is equals, at-least or at-most, with an optional tolerance on all three. Without by, any movement in the right direction holds. Give a money assertion a tolerance. 100 - 88.13 is 11.870000000000005, so { "op": "equals", "value": 11.87 } misses by a rounding error and reads as the app charging the wrong amount. "tolerance": 0.01 is the fix; the failure message says so when the miss is that small. A number is read out of displayed text, so "$1,234.50" compares as 1234.5. A reading with no digits in it is not zero. "sold out" reports that nothing was subtracted rather than pretending the total fell to nothing. These only work where a before-reading was taken, which today is reticle_act_and_wait. Asked anywhere else they report unknown with “no before-reading was taken”, never a failure, because nothing was compared and a false would blame the app for a reading nobody took. For the same reason a relative property is never already_true: unchanged is trivially true against a baseline taken a microsecond earlier, so the pre-action check skips it and lets the action decide. A saved flow keeps it: a step’s expect is a full predicate, satisfies included. A miss quotes the real value back at you:
store is optional. Leave it out and the path picks the store: if exactly one registered store exposes it, that one is read. Registering several is normal, so this is the common case rather than a shortcut. Name a store when two of them genuinely expose the same path, which is the only situation Reticle cannot resolve on its own. If no store exposes it, that is a failure rather than a question, and the miss names the stores it searched. This is the predicate that catches a UI-versus-store desync. A deploy that only looks shipped. Deterministically, in one call, with no model involved. On a miss it names the real store value and the keys that were available, so a failure is legible rather than a blind “no”.

animation

target narrows it to one element by Reticle ref (e12) or CSS selector. completed: false matches an animation that is still running.

settled

The page is quiet: no request in flight, no response body still streaming, no work that the app declared with reticle.busy() still pending, and no network or DOM activity for quietMs (default 500). This is the deterministic replacement for a fixed sleep, not a proof of anything. Combine it with a real consequence. Do not assert it alone. If you omit until on reticle_act_and_wait, it waits for exactly this. channel changes what counts as activity:
  • "network": requests only. No request in flight, and no request completed in the last quietMs. DOM changes, such as a running animation, do not hold it open. A saved net check with a count uses this, so a late duplicate request is still counted.
  • "app": the default activity plus every framework render commit. Use it for an app whose renders are its work. The default ignores render commits, because on many pages a clock or a counter renders every second, and the page would never go quiet.

compare

Two observed values must agree, and you do not have to know either one. Every other predicate checks one reading against a value you write down. That cannot catch a page that echoes the user’s input instead of the server’s answer unless you already know the right answer: a refund of 1187.01 that the server read as paise answered {"refunded":11.87}, the page showed ₹1,187.01, and every other channel was green. Each side reads one value from a source: A path is a field name or a dotted path into nested data (data.total, items.0.price), and it must end on a single value, not an object. as is value (the default) or number:
  • value is strict equality. The text "11.87" and the number 11.87 are different, and the failure says so and suggests as: "number".
  • number reads one number from each side. A JSON number is used as it is; a string contributes its only number, so "Refunded ₹1,187.01" reads as 1187.01. A string holding two numbers is unknown rather than guessed at, so scope the text to the one value. Add tolerance for a band.
A side nobody could read is never a pass. A call that never happened, a missing element or a missing field fails. A body that was not recorded, was truncated before the field, or had the field redacted is unknown. Comparing a source with itself is refused before it runs, because it would agree on every page. Two live sides (text, state) are also read before the action. If they already agreed, the result is already_true, because their agreement says nothing about what the action did. Put a net or signal side in the comparison to tie it to the action. Comparing request with response on the same call is legitimate, and it is the direct check for a unit mismatch:

Asserting a failure on purpose

A fault-injection test (mock a 500, check the error UI) is asserting that a request fails. Say so in the predicate, or the verdict will disagree with you:
Asserting only the error text is not enough, and this is the most common way to get a confusing verified: "no". Reticle watches for “the UI moved forward while a request failed”, a swallowed rejection, and one of the most expensive bugs it catches. A fault-injection test looks exactly like that bug from the outside: a request failed, the screen changed, nothing said that was the plan. Naming the failing call is what tells the two apart. A net clause with status of 400 or more, or "ok": false (the field for IPC, which has no status code), marks that call as expected to fail. A failure there is then not a disagreement. The same rule applies to the expect of each step in reticle_act { steps } and in a replayed flow: each step declares its own expected failures. A failure that no step declared is still reported.
Declare it in the top level or inside allOf. A declaration inside anyOf is deliberately ignored: only one branch of an anyOf has to hold, so honouring it would suppress a real contradiction on the strength of a branch that never ran. not is ignored for the same reason in reverse: not { net status: 500 } asserts the call did not fail.
One case is inferred for you: an element or text clause naming an access denial (“not authorised”, “sign in to continue”) marks the auth statuses as expected, because a denial screen is a correct outcome rather than a fault. Everything else has to be named.

Combinators

A combinator needs its own kind, and the children go in predicates, or in of, which is accepted as an alias for it. not takes a single child in predicate, or in of. A bare { "allOf": [ … ] } does not parse, and neither does { "kind": "allOf", "allOf": [ … ] }.Reticle refuses the call rather than running half of it. The refusal names the field, says that nothing ran and no verdict was produced, lists the fields allOf accepts, and shows a valid example. Better to be told than to get a green from an assertion that never executed.
allOf is the workhorse. “The signal fired and exactly one request went out and the console stayed clean” is a genuinely strong check, and it is one call. Each child reports its own evidence, in order. That is a real verdict for exactly the allOf above:
On a failure each child reports separately too, so you can see which one broke:

Timing and scoping

The since cursor comes from a prior reticle_act or reticle_observe response. When you leave it out, the window starts at your last act, so a signal from an earlier action cannot pass the check. Give since when the cause is not your last act, for example when you assert on a cursor that you took before two actions. Before your first act there is no last act, and the window is the whole buffer.

Where predicates get used

Naming the consequence before the action is the whole point.
Last modified on November 3, 2026