{ 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 atext assertion:
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.
"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:
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
containsorsatisfiesis required. With neither, the predicate matches any element with any text and cannot fail. satisfieson its own needs ascope(the element whose text is read, withself: truefor the root’s own subtree). Without one the locator is every element on the page, and the property would run against whichever matched first.
satisfies narrows, it never excuses.
net
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:
console
signal
reticle.signal(). dataMatches is shallow JSON matching, and * means “present, any value”.
count works here exactly as it does on net:
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
equals takes a literal, or an operator pattern:
Omit
equals entirely to assert presence. A real $length check against this fixture:
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
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: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.
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
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:
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.