The app-change radar
A scheduled structural crawl of your own app that reports what changed and which tests it puts at risk — deterministic, and it never clicks anything.
A test suite tells you when something broke. It tells you nothing at all about the thing that broke it, and it tells you that only after somebody ran it.
The radar looks from the other end. On a cadence you set, it visits your pages, fingerprints their structure, and compares each one against the last structure you accepted. What comes out is a short feed of differences in the app, each one carrying the list of tests that difference puts at risk — before anyone has run them.
The radar is a Pro plan feature, and it is deterministic end to end. No model is called, no AI key is spent, and an org with no key gets the whole radar and the whole at-risk list.
What a sweep does
A sweep takes one browser, one context, and opens one page per URL. It performs exactly two operations on each: navigate, and read the DOM.
It never clicks. Not a consent banner, not a cookie wall, not anything. That is a deliberate cost rather than an oversight: the crawler runs unattended, on a timer, carrying a real user's captured session, and the words on a consent button ("Accept", "Allow all", "I understand") are also the words on "Delete this workspace?". So on a site with a consent wall the radar fingerprints the consent wall — a wrong answer, chosen over a dangerous one. Put the radar behind a role whose session has already dismissed it.
Every URL is fenced to the base URL's origin. A seed pointing elsewhere is
rejected and reported; a link discovered mid-crawl that leaves the origin is not
followed. URLs are normalised before comparison — the fragment and the query
string are dropped, so /orders?page=2 and /orders?sort=asc are one page. A
screen whose identity lives in its query string is invisible to the radar.
What a fingerprint is, and what it deliberately ignores
The radar's promise is no news is no message. A feed that lights up because somebody reworded a button is not a signal, it is a reason to switch the feature off. So a page's fingerprint is built from element identity only:
| Hashed | Ignored |
|---|---|
tag, type, role, accessible name, data-testid, id | visible text, label, placeholder, aria-label, CSS path, page title, element order |
An element with no copy-free anchor is dropped from the fingerprint rather than hashed on a wobbly key — a button identified only by the word on it cannot be matched across two snapshots without matching on that word, which is the one thing this refuses to do. Drops are counted and reported in the sweep summary, never silent.
The practical consequence: rewording your copy produces no radar rows. Adding, removing or re-identifying a control produces one.
The feed
Open it at Quality → App radar on a project. Each row is one open question about one URL, of one of three kinds:
| Kind | Meaning |
|---|---|
| New page | A URL that was not in the baseline. |
| Page gone | A baselined URL that the sweep could not fingerprint. |
| Page changed | Elements added, removed, or re-identified. |
There is at most one open row per page. A sweep that still sees the same difference updates that row's "last seen" rather than stacking a duplicate, so the feed counts open questions, not sweeps. Suspect rows sort to the top; the rest is newest-first. Below the feed, Watched pages lists every URL the radar has captured, with its capture count and whether it has an accepted baseline yet.
The first time a URL is fingerprinted it becomes its own baseline, so a brand-new page does not open a change. That is why the very first sweep of a project is almost always quiet: it is building the thing later sweeps compare against.
A page the crawler could not load is not reported as gone. Only an observable HTTP error status produces "Page gone" — a flaky navigation must never read as a deleted page.
Under each row is the at-risk list: the tests that either click an element the
change touched (uses a moved element) or navigate to that page (visits this page), selector matches ranked first. A change with no tests under it is
labelled plainly — no test clicks an element here or navigates to this page, this
change is unguarded — which is usually the more interesting row of the two.
The at-risk list asks the run-selection gate, so a machine-written test still waiting for review is never named as coverage you have.
Clearing a row
Three buttons, and the difference between them is what happens to the baseline:
- Accept as expected — the baseline advances onto the snapshot just taken. The row closes and the same difference is never reported again. This is the button for "yes, we shipped that".
- Mark suspect — the row stays open and the baseline does not move. It keeps reappearing until the app goes back or somebody accepts it. Suspect is deliberately not an exit: it means "this looks like a regression", and quietly forgiving it would be the same lie as never reporting it.
- Dismiss — closes the row without moving the baseline, for a difference not worth tracking.
A row also closes on its own: if the app returns to its baseline shape, the next sweep resolves it as reverted.
Configuring it
Configure on the radar page opens the settings:
| Setting | Bounds | Default |
|---|---|---|
| Enabled | on/off | off |
| Cadence | hourly, daily, weekly, manual only | — |
| Seed URLs | up to 200, one per line, all on the project's origin | the project base URL |
| Max pages | 1–200 | 25 |
| Max depth | 0–5 | 2 |
Four more settings exist on PUT /api/projects/:projectId/radar with no field in
the dialog: elements per page (10–400, default 120), the sweep deadline
(10 s–30 min, default 5 minutes), and the environment and auth role the sweep
runs as. Pinning an environment and a role is what makes the radar sweep staging
as a logged-in user rather than the marketing page as an anonymous one — worth
doing, and for now an API call.
Manual only never schedules a sweep; Run sweep on the page starts one immediately whatever the cadence says.
Every bound reports whether it bit. A sweep that hit the page budget or the deadline lands as a partial sweep and says which bound stopped it, because a truncated crawl reported as a clean one is the failure this feature could most easily hide.
Reading an empty feed correctly
The empty states say three different things and they are not interchangeable:
- No baseline yet — the radar has never swept. An empty list means nobody has looked, not that the app is unchanged.
- Nothing changed since your last review — the last sweep found no structural difference the baseline does not already account for. This one is the goal.
- The radar could not be loaded — a fetch failed. This is not a clean sweep.
Two sweeps never overlap
A scheduled sweep is claimed in the database, so several replicas cannot both run it. A manual sweep on a project already sweeping is refused with a clear message rather than started. Both are correctness guards: two concurrent sweeps would read the same baselines and write two rows for the same URL, which is exactly the duplicate the feed is shaped to prevent.
The scheduler runs at most three sweeps per tick across the whole deployment and measures the next due time from now, not from the missed one — a server that was off for three days owes a daily radar one sweep, not three. A scheduled sweep that threw is written up as a failed sweep in the normal shape, so the status bar never shows last week's success.
If you have notifications on, the daily digest reports unreviewed radar changes across the workspace. It is silent when there is nothing unreviewed and silent again when nothing has changed since the last one.
From an agent
An MCP client can read the feed with get_app_changes — the open changes for
a project (optionally only those since a timestamp), each with its at-risk tests.
It leads with a state of not-configured, never-swept, clean or
changes-open, so an agent can tell "nothing is wrong" from "nobody has looked".
That is the whole radar surface over MCP. Sweeping, configuring, and accepting or dismissing a change are dashboard and API actions — none of them has a tool.
Limits worth knowing
- Pro plan and above, including the read side. The feed is the product, not a preview of it.
- The crawl is structural. It sees controls appearing and disappearing; it does not see a wrong total, a broken calculation or a changed policy. That is what business rules and actual tests are for.
- A page behind a consent wall, a login the configured role cannot pass, or a client-side route the crawler cannot reach by link is fingerprinted as whatever it could load — which is why a partial sweep is labelled as one.
- A control identified only by an unstable id (a generated
:r0:, a uuid, a long digit run) is dropped from the fingerprint rather than reported as churning. The drop is counted, not hidden. - A control the radar could only anchor by being the only one of its tag on the page leaves the fingerprint the moment a second one appears — reported as a removal, which is a real structural change described in unhelpful words.
- The radar's own history is not swept on a retention schedule today. Snapshots and resolved changes accumulate.
- Changing how fingerprints are computed invalidates every baseline; they are version-stamped so that is visible rather than silent.
See also
- Staged repairs & the drift gardener — what the radar hands to the repair loop
- Scheduled runs — the same tick drives both
vera check— impact from a code diff, rather than from the running app- Issues & triage — where a confirmed regression goes next