Skip to content

refactor: one control surface, and no Tauri runtime in a headless browser - #21

Merged
pathscale merged 22 commits into
masterfrom
feat/one-control-surface
Sep 11, 2026
Merged

refactor: one control surface, and no Tauri runtime in a headless browser#21
pathscale merged 22 commits into
masterfrom
feat/one-control-surface

Conversation

@pathscale

@pathscale pathscale commented Sep 9, 2026

Copy link
Copy Markdown
Owner

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.

meh 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.
@pathscale
pathscale merged commit c25b1d2 into master Sep 11, 2026
3 of 6 checks passed
@pathscale
pathscale deleted the feat/one-control-surface branch September 11, 2026 19:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant