refactor: one control surface, and no Tauri runtime in a headless browser - #21
Merged
Conversation
added 21 commits
September 9, 2026 18:28
`--descriptor <path>` for a file that does not exist fell through to the same discovery a bare invocation uses, which attaches to the newest descriptor in the temporary directory. On a machine running several suites at once that is another site's host: the connection succeeds, the tree comes back, and the run confidently reports a page nobody asked about. One agent spent a round trip reading a different site's document before noticing. Naming a descriptor is a statement about which host to attach to, so a typo is a failure rather than an invitation to pick one.
`Expect::Contrast` and `ps-qa dom` matched with `name.contains(want) || role.contains(want)`, which is not the grammar the rest of the harness reads. A subject spelled `link:crates.vip` therefore matched nothing: no accessible name contains that literal text and no role does either. The audit then reported "no visible painted node matched", which reads as a page whose text does not paint rather than as a selector nobody had implemented, and the same spelling in `dom` came back with zero rows while a check addressed the node perfectly well. Both now go through `selector_matches_node`, so `role:name`, `#id` and `@slot` mean the same thing everywhere. A grammar selector the document cannot answer at all -- a role no node carries, a DOM id or slot nobody declares -- is rejected by name instead of quietly selecting an empty set. A bare word still searches names, roles and values in `dom`, which is the other half of that command's job.
`Vanishes` filtered its matches on `visible && paints`, and a box is not a place on the screen. The responsive pattern of one desktop control plus a narrow duplicate parked outside the window therefore defeated it completely: the duplicate reports visible, keeps a full-size box, and sits at a negative x, so every dialog with a hidden mirror copy was reported as "still on screen; it did not close" no matter how correctly it closed. The expectation was unusable on any site built that way. A match now also has to be somewhere `offscreen` says a press could land, which is the predicate the driving side already uses to decide the same question. The failure message carries the position, so the next reader can see which copy answered.
`Paints`, `PaintsNamed`, `PaintsMore` and `Count` judge on geometry, and for good reason: the semantic tree's `visible` walks ancestors for `display:none` and `aria-hidden`, disagrees with what the renderer drew, and trusting it once reported a screen full of icons as painting nothing. The cost of that is a whole class of component nothing could assert. An Accordion, a Collapsible or a Tabs panel hides by flipping `hidden` while keeping its box, so every geometry assertion is satisfied by the closed panel exactly as it is by the open one. `Vanishes` reads the flag and can prove such a thing closed; nothing could prove it open, which is why disclosure coverage stopped where it did on every site that has one. `require_visible` is that question, opt-in per check so the default keeps the geometry semantics every existing check was written against. The flag is already on the wire in `SemanticNode`, so this needs nothing from the runtime.
`setup_type_into` sends `SetValue` and moves on. The acknowledgement says the runtime applied it; the semantic tree the baseline reads is a separate observation, and a baseline taken in between records the pre-setup value. The difference between that baseline and the tree after the action is then the harness's own typing, so a `ValueChanges` on the setup field passes whether or not the measured action did anything. The test writes that false pass out: a click that changes nothing, and an `Ok` verdict. The setup step now waits for the value to appear on the exact node `SetValue` addressed, and says so plainly if it never does. The degenerate form of the same check is rejected at load: a change expectation on the field its own setup typed into, with no action declared at all, has nothing between its two observations but the setup and cannot report anything about the application.
Between the action and judging it, the runner waited for a committed frame for the whole of the check's declared outcome budget, described as the cheapest happens-after boundary. A host with no compositor never commits one, and neither does a valid action that changes no pixels, so that wait ran to its deadline and the check's reported duration became its own budget: 211ms against a declared 200, 3004ms against 3000. Read as latency those numbers say a control is on the edge of its deadline. They are a constant, and the outcome had usually arrived in a few milliseconds. `settle_for_outcome` already inspects before it waits and waits on the same paint stream against the same deadline, so the boundary is not lost by removing the blocking one in front of it. The arming stays, and is handed on so a frame committed between the action and the settle loop is not discarded by a second arm. The test drives a host that serves the paint stream and never commits: the old boundary spends the entire budget against it, the path the runner now takes returns as soon as the tree answers.
A check's navigation had a fixed 900ms with no way to say otherwise, and `click` has taken an explicit deadline for a while. Any route change that costs a live network round trip lands on either side of that number, so whether a check passes depends on the day. The message it produced was also about the wrong thing: `could not open "Crates"` reads as a missing tab, and the reader goes looking for a control that is there. `open_timeout_ms` is that budget, independent of `outcome_timeout_ms` so that covering a slow route does not weaken the interaction the check exists to measure. The arrival failure now says the control was activated and the destination did not paint in time, and names the budget it missed.
The runner bucketed every check by the surface its `open` named, stable-sorted by that bucket, and let a check with no `open` inherit the previous check's bucket across every file at once. Four sites paid for it: groups written as ordered sequences came back interleaved, so steps ran between steps; one site's checks inherited a surface from a file they have no relationship with and measured a page they had never navigated to, and passed; and another could not declare a second surface at all without its existing sequences coming apart. What the bucketing bought was mount amortization. What it cost is the one property an ordered suite needs, which is that the order is the one somebody wrote down. Files in name order, checks in declaration order, and nothing moves but the destructive tail, which is a correctness requirement rather than an optimization. Inheriting the previous check's screen inside one file is how a sequence is written and stays. Across files it is now an error naming both files and the surface that would have been inherited: a reader of the second file has no way to know what the first one left in front of them.
The README described neither `require_visible` nor `open_timeout_ms`, and still said a bare substring is all `dom` matches.
Two new check fields, `require_visible` and `open_timeout_ms`, both defaulted so existing manifests parse unchanged. Two behaviour changes a suite will notice: checks now run in declaration order rather than bucketed by surface, and a file whose first check declares no `open` while an earlier file navigated somewhere is rejected instead of inheriting that surface in silence.
`Enabled` and `Disabled` ask two questions at once: the tree says this control accepts input, and it has a box a person could aim at. The second half is the font question `Present` already exists for, and CI runs a host with no font catalogue. A `w-full` primary call to action collapses to its label and lays out `0x44` there, carrying `enabled=true` in the very tree the check reads, and both verdicts refuse it. Two sites hit this within a day. One rewrote two checks to assert what the button gates rather than the button; the other dropped to `Present` and lost the state assertion entirely. `text_sized` trades the box for the weaker thing that survives a fontless host: the node was laid out. Opt-in and per check, on the shape `require_visible` already set, so no existing check changes meaning and no suite has to migrate. Only the box is given up: an absent subject, one that never reached layout, and one carrying the wrong flag all still fail, and the field is refused on any expectation that would not consult it. The failure message names the field when the flag was already right and only the box was empty, since that is a one-word fix in the check file.
…needs The expectation table never listed `Present`, which is the geometry-axis answer to a fontless host and the thing `text_sized` has to be distinguished from. Add both, with the measurement that produced the field.
The semantic tree, the capture surface and input injection lived in `tauri-runtime-blitz`, beside the Tauri runtime. Nothing in them needs a window: they read and drive a `blitz-dom` document. Depending on them meant compiling Tauri anyway, and on Linux that means GTK, so a headless host pulled in system libraries to build a binary that opens nothing. A runtime bridges Tauri to Blitz and owns a native window; it is not where an inspection service lives. So this is the whole of `agent.rs`, taken from `fix/semantic-tree-tables-and-text` rather than from master, because master silently lacks the eight naming fixes on that branch. All 27 of its tests come with it. It is behind two features, cut on the blitz boundary rather than on what any consumer happened to want. `engine` is the tree and input injection; `capture` adds the offscreen paint, the diagnostic snapshots and the renderer metrics, and it is the only thing that pulls in `blitz-shell`. With neither enabled this crate is what it has always been: the vocabulary, at serde plus endpoint-libs. `cargo tree -p ps-qa` is the check and CI fails on it. The role table goes with the move rather than after it, because keeping it would have made three copies where there were two. `blitz-dom` owns the rules now, in `accessibility::implicit_role`, and this consumes them. What is left here is a projection: blitz-dom answers in AccessKit's vocabulary, and this wire answers in ARIA role names. That projection is deliberately lossy, and the loss is the compatibility guarantee. Eleven fleet sites and roughly 1,700 checks are written against the role strings this surface has always reported, so every arm reproduces one of them and `role_projection_tests` asserts it element by element. `<p>`, `<article>`, `<code>` and the rest carry a distinct AccessKit role and have always arrived here as `generic`; making any of them more precise is a decision to take with the checks that read it, not a consequence of the tables becoming one. The one rule that changes: `<th>` is a `columnheader` or a `rowheader`, as blitz-dom has always said and this surface did not. blitz-control-protocol 0.5.0, since the crate's shape is public.
`agent_control_server.rs` from `tauri-runtime-blitz`: the Unix socket,
the descriptor, the MCP framing and the connection loop. It is a
transport, so it lives beside the protocol it frames rather than inside a
window runtime that happened to be its first host.
It knew three things about its host that it had no way to know, and each
was true only while there was one host:
- `serverInfo.name` was the literal `tauri-runtime-blitz`, so the
headless browser has been introducing itself as the Tauri runtime.
- `rendererRevision` was this crate's own version.
- `tools/list` advertised diagnostics when *this crate* had been built
with them, and the `Diagnostics` bridge arm did not compile at all
without them.
All three now come from the caller, as `Host`. A host that cannot collect
diagnostics says so, and its bridge answers a call that arrives anyway;
that is better than the transport failing to compile the arm, and it is
what makes the server free of the engine.
Free except for one line: it holds a deep-profiling session open while a
tool can attach, and that session belongs to `blitz-shell`. So it is
behind `capture`, and a `server` build with no engine links no blitz at
all. The gate is on the blitz boundary rather than on what the transport
is for, which is the same reason `client` is a sibling of this and not a
layer above it.
Every test comes with it, plus three. Two are for the host-supplied
identity and capability. The third is the teardown assertion from the
browser's own control server, which this replaces: dropping the server
removes the socket and the descriptor, because leaving either behind
advertises a host that is not there.
The second transport. An embedder that already holds the document calls straight in: no socket to bind, no descriptor to publish, no serialisation, no second thread. AgencyZero is about to be both at once, driven by agents over the socket while it drives a browser it embeds through this, which is why the socket cannot be the only transport. `DocumentControl` holds the state one request leaves for the next and that the core deliberately does not: the revision an `Inspect` reports, where an injected pointer is, which buttons it is holding, and the reusable offscreen surface a capture draws into. It also holds the action dispatch, which existed twice. The Tauri runtime and the headless browser each turned an `AgentAction` into engine calls, and the two had drifted. `SetValue` is where it shows: one selected the field's contents and replaced them, the other counted bytes. Only the second works, because `select_all` resolves its ends through the laid out text, so with no font catalogue registered the selection comes back collapsed and the typed string lands after the old one instead of replacing it. A host that behaves differently depending on which fonts the machine has is a harness that reports different verdicts on CI and on a laptop, so the byte arithmetic is what survives. Nine tests, against a real document with no listener anywhere: a click runs the page's handler and the response waits for it, a value really replaces what was there with no fonts installed, focus moves without activating, an unknown node is an error rather than a panic, and the process lifecycle is refused rather than acknowledged. An `Ack` for something that did not happen is the failure mode this surface has to avoid, because a check then reports the page as broken.
`ps-qa`'s `inspector` module, moved to the crate that defines the protocol. It is the client half of the socket transport: descriptor discovery, the retrying connect, the id-matched exchange, and the event queue that keeps a pushed notification from being handed back as though it were a response. It is a sibling of the server rather than a layer above it, because a peer can be both. An application driven by agents while it drives a browser it embeds is a client and a server at once, and the two halves share the vocabulary, the framing and the error taxonomy without either being built on the other. Neither needs blitz: the framing is the protocol, the document is not. There was a second client. The browser's `chuzz-inspect` built its requests as untyped JSON by hand, with a comment explaining which fields the runtime would and would not accept, because nothing in that repository could speak the typed shape. It is deleted in the change that follows, and points here instead. `ps-qa` keeps the module name it has always used, as a re-export, so the three hundred call sites below it are unchanged and the move stays visible rather than buried in a rename. It takes the `client` feature and nothing else, and `cargo tree -p ps-qa` is still 132 crates with no renderer, window runtime or GPU stack in it.
`blitz-script` takes it behind its `debug-control` feature, so ps-blitz depended on this repository. With the control core here, this repository needs `blitz-dom`. Two repositories pointing at each other have no release order: whichever publishes first publishes against the other's previous version, and that is the problem this work exists to remove. It is the lower half of the pair, so it goes to ps-blitz, where `blitz-script` can take it by path. Nothing in it knew about this harness: getrandom, serde and serde_json, and a loopback HTTP server. CI runs the workspace now rather than naming crates, so a member added later is covered without anyone remembering to add it. Two graph checks guard the boundary from both sides: `ps-qa` must contain no renderer, and this crate built with no features must not either, because the core being here is exactly what could put one in every consumer.
Twenty-three tests lived in `tauri-runtime-blitz`'s `runtime.rs`, beside a window, and every one of them is about a document: what a node is named, what role it is given, whether it is visible, what an activation reaches, what a capture draws, what a diagnostic row reports, and how long an action gets to settle. None of them opens a window and none of them needs Tauri. One of them can now run where it could not before. `setting_a_node_value_replaces_text_and_dispatches_input` was macOS-only, because replacing a field's contents went through `select_all`, which resolves over shaped text: with no font catalogue registered the selection came back empty and the replacement landed after the old value. The merged implementation counts bytes, which is what the headless browser always did, so there is no layout in the path and the test runs everywhere. That is the property it was always meant to assert. Protocol tests: 62 to 85.
`Relaunch` and `Quit` are refused by the document core, correctly: a document cannot restart the program displaying it. Somebody above it has to answer, and which of them depends on the application. AgencyZero drains its persistence layer and hands over to a restart supervisor. That registration used to be `tauri_runtime_blitz::set_agent_control_handler`, so an application registered an answer for an inspection service through a window runtime, and named this crate's types through that runtime's re-export of it. Depending on a renderer to answer "restart yourself" is the edge being removed, and the handler has to land somewhere that is not a host, because more than one host serves the same request: the Tauri runtime does it for a window today, and an embedder driving a browser in process will do it for that browser next. No feature gates it. The handler is an `Arc` and a lock over the vocabulary, so a consumer that registers one pays serde and nothing else.
This was referenced Sep 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Centralize the document control implementation and wire vocabulary in blitz-control-protocol, so the headless browser and Tauri runtime share actions while ps-qa remains a protocol client without a renderer dependency.
The release prepares blitz-control-protocol 0.5.0 and ps-qa 0.7.1. Native select values/selectedness are exposed consistently. Explicit role selectors can address calendar gridcells. Keyboard chords cover application shortcuts, and real pointer dragging/cancellation is available through both the CLI and declarative checks.
Paint assertions now inspect the result after their declared input. Previously a theme toggle could pass or fail based on the previous theme. Contrast also honors the declared outcome/stability windows instead of judging an intermediate transition frame. A native CLI fixture verifies immediate and delayed repairs and persistent contrast failure, along with gridcell activation, captured dragging and cancellation.
Validation on the coordinated local candidate stack: ps-qa tests, clippy with warnings denied, and formatting pass; the explicit native CLI regression passes all six scenarios. UI's final sweep passes 273 checks across 75 fixtures, Worktables 112/112, JS Software 316/316, and Web3 103/103. Honey passes 193/196, with the three failures isolated to its backend's empty API-key regeneration response; the dedicated error path passes 18/18 and recovery flow 33/33. The native CLI regression requires a font-enabled QA_HOST and is run explicitly; the default test run marks it ignored.
Publication order: ps-blitz #95, then this PR, then tauri-runtime-blitz #57, chuzz #45, UI and consumers. The runtime requires protocol 0.5, so it cannot precede this publication. Registry dependency failures before ps-blitz 0.4.8 publishes are expected; verify availability before advancing.
Owner PR review and approval are required before publication. The capture feature still reaches blitz-shell for frame diagnostics; the current change does not claim to remove that dependency. AgencyZero's UI integration remains a separate downstream gate.