Skip to main content
A predicate declares what should be true, as { kind, ...fields }. Reticle has twelve kinds: signal, state, net, route, element, text, console, animation, settled, 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_wait_for. 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 leaves

element

query accepts role, name, text, label, placeholder, testid, alt, value, by, component, source, scope, self, attrs. state is one of visible, hidden, enabled, disabled, checked, expanded, 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_query 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 and text are accepted as aliases and rewritten to it. absent: true asserts the text is not present, and visible: true requires it to be on screen rather than merely in the DOM. 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 shallow field match, keyed like signal.dataMatches and taking the same operators (*, $gte, $contains). 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. Both require body capture, reticle({ captureNetworkBodies: true }), and say so when the body was never recorded rather than reporting an ordinary mismatch.

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 and urlContains as aliases for contains, but the canonical spellings are the ones above. A passing route verdict quotes the transition itself:
route asserts the route changed. Asserting the route you are already on always fails, 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.

signal

The app emitted a named signal via reticle.signal(). dataMatches is shallow JSON matching, and * means “present, any value”. 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: 0 is a claim of its own (the signal never fired) and is not the same as leaving count out, which asserts presence. 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:
Note grade: state in the because line, and that the evidence collapses each row to a size marker rather than returning 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 cannot keep one yet: FlowExpect has no slot for satisfies, and rather than record a weaker assertion than the one you made, the converter records none. 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. completed: false matches an animation that is still running.

settled

Network and DOM idle. This is the deterministic replacement for a fixed sleep, not a proof of anything: combine it with a real consequence rather than asserting it alone. Omitting until on reticle_act_and_wait waits for exactly this.

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, which is the honest field for IPC, where there is no status code, marks that call as expected to fail, and a failure there stops being a disagreement.
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 real refusal names the field, the fix and the consequence: “predicates: Required; unknown field allOf. Nothing ran, the predicate was not evaluated, so no verdict was produced. allOf accepts: predicates.” 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 response. Use it whenever you are asserting something an action was supposed to cause. Without it, an assertion can pass on an event from two clicks ago. That applies to every one of the five kinds above, not only to network calls: an unscoped signal matches a fire from an earlier action just as readily.

Where predicates get used

Naming the consequence before the action is the whole point.
Last modified on September 23, 2026