Commit Graph

348 Commits

Author SHA1 Message Date
Hubert Gancarczyk 6e862a60b3 test(keyboard,flow): pin the arms mutation showed unpinned, and the new warning route
Each of these was written against a mutant that kept the suite green, and each
was re-run with that mutant applied to confirm it now fails.

keyboard-clear:
- the atomic cooldown EXPIRING, at the boundary. Arming and not-arming were
  covered; nothing advanced the clock, so widening the constant to a permanent
  skip stayed green.
- `WEDGED_RPC_MS` driven through `ping`. Its rationale names both legs and only
  the write drove it — and `ping` is the leg a helper that goes silent after
  accepting the socket actually spends its time in.
- a helper reporting a protocol NEWER than the gate still takes the atomic path
  (`>=`, not `===`).
- the `sized` tie-break: an unmeasurable focused field tying the longest reading
  keeps the note's BLIND clause, in the walk order where only that arm decides it.
- the read-back arm outranking `no_focused_input`, and the read-back clause
  staying off the paths that never took one.
- a `{{secret:…}}` request on the no-writable-field arm is not told to read the
  field back.
- the cross-platform result SHAPE, which a destructure had stopped checking.

flow runner:
- a `keyboard` clear's warning carried out of a composed `flow-execute`, its
  repeats collapsed, its depth composed, and a green sub-run left warning-free.
- a tool answering `note: ""` raises no warning.

Two gaps from the review are left open, both reported rather than closed: the
blueprint's `setText` wire call (there is no android-devtools blueprint harness
to hang it on, and the call is exercised end to end against a real protocol-2
helper), and `download-native-binaries.sh`'s gate (no shell-test harness in the
repo; both arms were driven by hand against the live release).
2026-08-24 20:50:02 +02:00
Hubert Gancarczyk e5e34899f2 fix(keyboard): do not backspace over a reading that could be a placeholder
A clear-only call reads the field back after the DEL and sends a delete run
over whatever it still reports. An empty field reports its PLACEHOLDER in the
same `text` attribute as a value, so the ordinary case — a search box, a login
field — measured the hint and ran `KEYCODE_MOVE_END` plus hint + 8 backspaces
into a field that was already empty.

Those keys are not absorbed. `BaseKeyListener` leaves `KEYCODE_DEL` unconsumed
when there is nothing to delete, so each one reaches the app, and a widget that
reads backspace-on-empty as "remove the item before the caret" — a chip or
recipient field, a segmented OTP box, a React Native `TextInput` with an
`onKeyPress` handler — is emptied by a call that then returns `cleared: true`.
The docblock's justification ("backspace on an empty field is a no-op") holds
for the field's text and not for what the app receives.

Measured on an API 36 emulator against a one-`EditText` app whose key listener
drops a chip on backspace-with-an-empty-buffer, with the helper too old for the
atomic path — which is every device until the protocol-2 release ships:

  before  keys=17  emptyBksp=13  chips=0  value=[]
  after   keys=3   emptyBksp=0   chips=3  value=[]

The measurement now reads the `hint` out where the source carries one, so a
field showing its placeholder measures 0 and the read-back CONFIRMS the clear
instead of rescuing it. Where no hint is carried — API 34's dump, and the
devtools helper's `captureXml` on every level — a positive reading can be
either, and the run no longer goes out on it: the note reports the chord as
unconfirmed, which is what the base did before the read-back existed.

The rescue still fires on a residue the source can prove, verified on the same
device with a widget that swallows the Ctrl+A chord: the field ended empty
after a sized run.
2026-08-24 20:18:31 +02:00
Hubert Gancarczyk 679c78a7b8 docs(keyboard,flow): correct what warns, what a password box verifies, and how a helper goes stale
Six statements this PR left contradicting the code it shipped:

- `StepReport.warning` and `argent-cli`'s copy enumerated the raisers without
  the `run-sequence` route, and without saying that `keyboard` is the only
  tool read this way.
- `reliability-and-recovery.md` said raw `tool:` steps never warn. A raw
  `keyboard` clear does, and the same file's own table lists that row.
- `live-authoring.md` and `argent-qa-flows` both said a password box can never
  produce a verified clear. The helper reads one back whenever the write is
  empty, and says so in its own comment: only `{ clear: true, text: "..." }` on
  a password box is unverifiable.
- `flow-yaml.md`'s canonical `clear` section, which both new passages link back
  to, never mentioned the note or the warning at all.
- The `flow-execute` description listed only the idle and selector-less-gesture
  warning families, so an agent reading it could not learn that a clear warns.
- `helper_outdated` was blamed on "whatever started first, possibly from
  another argent install". `spawnHelper` always starts a fresh `am instrument`
  and connects to the port that process announces, so a foreign helper is never
  on our socket. The real routes are a lagging release and a stale install
  cache.

Also: the `keyboard` description now says the verified Android path is one
accessibility edit rather than key events, so `{ clear, text }` fires no
per-key handler there; and the last copy of the pre-atomic "~41s" worst case
is reconciled with the budgets the tool actually declares.
2026-08-24 12:26:12 +02:00
Hubert Gancarczyk e4fbf7c5b7 test(keyboard,flow): pin the warning's scope, the no-field note, and two boundaries
Each gap below was found by editing the production line and watching the
suites stay green.

- Nothing pinned the SCOPE of the note-to-warning promotion. Narrowing it to
  `keyboard` left all 1226 flow tests green, so a tool returning a healthy
  `note` could be promoted for good with nothing to object. Cover both
  directions, at the top level and one level down inside `run-sequence`.
- The `run-sequence` spelling of a clear had no test at all, in either
  direction, nor a case for a nested report whose shape is unrecognised.
- `dispatchForResult`'s `?? null` was unpinned: dropping it turns a tool that
  returned nothing into the skip a hang-up produces, and no test noticed.
- The note's wording for `no_focused_input` / `not_editable` was unpinned -
  only the `why` fragment was asserted - with a contrast case that keeps the
  plain wording for a refusal.
- `WEDGED_RPC_MS` was loosely pinned: both arms had a test, the boundary did
  not, so widening 2s to 14s stayed green.
- `keyboard-android.test.ts` never reset the atomic cooldown, so the first
  clear to fail left every later test in the file on the injected path by a
  different route.
2026-08-24 12:20:06 +02:00
Hubert Gancarczyk 6be9deda58 fix(flow): surface a tool step's note as its warning, not only a directive's
`note` is the one signal saying whether an Android clear was the verified kind,
and only the `type` directive turned it into a step warning. A flow that carries
the same clear as a RAW tool step got nothing: `flow-add-step` has no
`keyboard` → `type` rewrite, so a recorded clear replays as a tool step whose
report put the note inside `result` — and `argent-cli`'s own `StepReport` has no
`result` field, so the CLI printed `✓ NN tool keyboard` and nothing else.

`argent flow run` is the runner the QA-flow skill prescribes for CI, so on the
raw form the warning gate was vacuous: the flow completed clean over an
unverified clear with no on-screen signal.

Verified end to end against a Pixel 7 / API 34: a one-step flow
`- tool: keyboard / args: { clear: true, text: abc }` now reports
`"status": "pass"` with the note in `warning`.
2026-08-20 22:16:21 +02:00
Hubert Gancarczyk aa33dd970d test(android-devtools): pin which RPCs get the long client timeout
Nothing in the repo covered `android-devtools-client.ts` at all, so reverting
`method === "getHierarchy" || method === "setText"` to the single-method ternary
left every keyboard suite green — a one-token edit that reads as tidying and
reintroduces a doubled write: a host that gives up at 5s reports "fall back",
the injected clear runs, and the accessibility `ACTION_SET_TEXT` it stopped
waiting for can still land in the middle of it.

The test drives the real client over a real socket that never answers, and reads
the budget out of the timeout it raises.
2026-08-20 22:06:53 +02:00
Hubert Gancarczyk 37f995ef10 test(keyboard): pin the error_kind carried through the kept-selection rewrap
The accompanying test seeded a cause whose `error_kind` was already
`"subprocess"` — identical to the `??` fallback — so it asserted an incidental
property rather than the propagation. Replacing the whole expression with a
hardcoded `error_kind: "subprocess"` left all three keyboard suites green.

`injectAndroidText` can raise `timeout` and `dependency_missing` as well, and
both sibling rewraps in `android-input.ts` are pinned against exactly that.
2026-08-20 22:05:56 +02:00
Hubert Gancarczyk 33342993df fix(keyboard): do not send the measurement back into a start this call abandoned
`devtoolsHierarchyReader`'s premise is "with it down there is no contention", and
its gate counts STARTING as live. That is right when ANOTHER caller is spawning
the helper — joining that init beats racing the holder it is about to become.
It is wrong for the one caller that started it in THIS call and gave up on it:
the node it finds is its own, and the init it awaits is the one the atomic
attempt already abandoned, so the read waits out its whole share for an answer
that has already failed to arrive once.

That caller now passes no reader, which puts it back on the dump — exactly what
ran before the helper was consulted at all.

Measured on a Pixel 7 / API 34, one `{ clear: true }` against a start that never
settles: 16049 ms before, 11054 ms after, with the field measured (not the blind
run) in both.
2026-08-20 22:01:38 +02:00
Hubert Gancarczyk 788959f8ae fix(keyboard): arm the atomic-clear cooldown for the failures it was written for
The mark was written only when `resolveService` rejected BEFORE the 8s budget.
Every failure ATOMIC_START_COOLDOWN_MS names is slower than that — its own
docblock quotes the blueprint's 60s install plus 30s readiness wait — so the
cooldown armed for the cheap failures and stayed unarmed for the expensive ones.
A start failing half a second past the budget left every clear paying the full
budget and starting a fresh install, forever: the exact outcome the constant
says it prevents.

The race abandons the loser rather than cancelling it, so the rejection arrives
long after this call returned. It is recorded where it actually lands — on the
`catch` attached to the abandoned promise, which until now discarded it. (That
handler was never needed to keep the rejection handled: `Promise.race`
subscribes to every input, so nothing was going unhandled. It has a job now.)

The other half is a helper the registry DOES resolve and that never answers: it
returns `rpc_failed`, recorded nothing, and the successful resolve had already
deleted any mark — so a wedged helper was re-asked on every clear forever, at
5s for a hung `ping` or 15s for a hung `setText`. That is the more expensive
state, not a cheaper one. A failed leg slower than WEDGED_RPC_MS now arms the
same cooldown; a fast rejection still does not, since retrying it costs nothing.

Measured on a Pixel 7 / API 34, three consecutive clears each:
  start rejecting at 8.5s — before 11899/11594/11366 ms, after 11504/2826/2840
  helper up and silent    — after 17307/1303/1289 ms
2026-08-20 21:54:37 +02:00
Hubert Gancarczyk 2e27e08723 fix(keyboard): stop saying nothing was modified after a replace the widget accepted
`unverifiable` and `value_mismatch` fall through with `applied: true`, and on a
level whose `input` has no `keycombination` the fallback is the legacy one — its
refusal says "Nothing was modified and nothing was typed." Nothing ties the
helper's protocol to the API level (the manifest carries no minSdk gate, and the
helper is measured running on API 30), so protocol 2 plus no `keycombination` is
an ordinary configuration.

"Nothing was modified" is the sentence that makes a retry against the original
value look safe, and it contradicts the doubling warning the note gives for the
same two reasons.

Verified on a real API 30 emulator with a 160-character field: the refusal now
reads "Nothing was typed. An accessibility replace was ACCEPTED by the widget
before this ran, so the field may already hold the value you asked for."
2026-08-20 21:46:31 +02:00
Hubert Gancarczyk bd372218c7 fix(keyboard): stop the post-chord refusal asserting what the device did
The over-length message reached from the select-all path said "The select-all
did not take on this field" and "the delete that followed the select-all removed
one character from it". Neither is known at that point. The count comes from the
same whole-screen measurement the message itself warns about two sentences
later: it can be another window's focused field, or this field's own
placeholder, which the view hierarchy reports in the same attribute as a value.

Measured on a Pixel 7 / API 34 against a focused field with a 181-character
placeholder: the chord took, the field ended EMPTY with one text change, and the
call still returned 400 — quoting 181 characters and both device claims, every
one of them false.

The refusal stays: a residue that long really cannot be backspaced away, and
shipping `cleared: true` over it is the failure this whole change exists to
close. What it now says is the pair of states the caller can actually act on.
2026-08-20 21:39:34 +02:00
Hubert Gancarczyk 3daac0618d fix(keyboard): honour a hang-up before pressing the key that follows a clear
The abort check between the tiers guards only the failing arm, so a `key` that
followed a SUCCESSFUL accessibility replace went out with no signal check at
all. Both twins do the opposite — `simulator-server-keys.ts` and
`platforms/chromium.ts` check immediately before the press, the latter with a
comment naming `{ clear, key: "enter" }`.

So a cancelled `{ clear: true, key: "enter" }` submitted the field when the
helper was present and did not when it was absent: same request, same hang-up,
opposite side effect, decided by a path the caller cannot see. The window is the
whole atomic leg — the start budget plus both RPCs.

The injected arm's own key press gets the same check, for the same reason: the
clear and the text are already on the device and cannot be taken back, but the
submit can still be withheld.

Verified on a Pixel 7 / API 34 by hanging up 2s into a 6s replace and sending
`{ clear: true, key: "space" }` — the space is observable in the field. Before:
`value=[Monday ] changes=1`. After: `value=[Monday] changes=0`.
2026-08-20 21:34:07 +02:00
Hubert Gancarczyk be0322805e fix(keyboard): do not tell the agent to read back a field it filled from a secret
The note closed with "Read the field back if the exact value matters" on every
call, including one whose text came from a `{{secret:…}}` placeholder — arriving
in the same response as the notice saying the after-typing screenshot was
skipped, and against `argent-device-interact`, which tells the agent to submit or
navigate away rather than `describe` such a field.

The pairing is not rare: a password field always answers `unverifiable` for a
non-empty write, so a credential entry produces this note on almost every call.

Verified on a Pixel 7 / API 34 with a real `ARGENT_SECRET_` placeholder resolved
through the tool: the note now says not to read the field back, and names the
app's own result instead.
2026-08-20 21:25:18 +02:00
Hubert Gancarczyk e3245c4c4f fix(keyboard): stop reporting a read-back the clear never got
The `select-all` arm asserted "The field was read back afterwards and nothing was
left to remove", but three different read-backs reach that path and only one of
them saw an empty field. A screen the reader could not capture was covered by
the caveat that followed; a field the reader DID capture and could not measure
was not — a focused password box is floored to the blind count rather than
dropped, so it lands here with the note claiming a positive reading.

That is the wrong field to be wrong about: on a password box whose widget
swallows the chord — the failure this change exists to close — the caller was
told the box is clear while the credential is still in it.

The outcome now carries which of the two happened, and the note says so.

Verified on a Pixel 7 / API 34 against a focused password field: the note reads
"Nothing confirmed the chord took".
2026-08-20 21:20:42 +02:00
Hubert Gancarczyk 32aeccad8b fix(keyboard): stop reading a placeholder as proof the select-all failed
The read-back gate feeds on `measureFocusedTextLength`, which reads the dump's
`text` attribute — and an EMPTY field reports its placeholder there. Neither
reader carries a hint attribute to tell the two apart on the levels this path
serves: checked on API 34, `uiautomator` emits no `hint`, and the helper's
`captureXml` does not either.

Measured on a Pixel 7 / API 34 against a hinted native `EditText` holding
"Monday": the chord took, the field ended empty with exactly one text change,
the read-back returned "type here" — and the note came back saying "the chord
did NOT take on this field", contradicting the `cleared: true` it ships with.
Every search box, login field and URL bar reads that way.

The run still goes out: backspace on an empty field is a no-op, while skipping
it on a real residue leaves the value the clear was asked to remove. What
changes is the claim — the arm now reports the reading and what the run did,
and says why the reading cannot separate the two.
2026-08-20 21:16:04 +02:00
Hubert Gancarczyk b69f2ff498 fix(keyboard): stop warning about a doubled value on a clear-only call
The doubling warning needs a SECOND write to exist. A clear-only call has none —
its fallback deletes — and the accepted replace wrote the empty string, which is
what the fallback leaves behind anyway. So `{ clear: true }` came back saying
the field "may now hold that value with the fallback's text added to it" over a
call that carried no text, in the same note that had just said the field read
back empty.

Both pinned cases passed `text: "abc"`, so the clear-only shape was unpinned.
2026-08-20 21:11:27 +02:00
Hubert Gancarczyk b742d2e404 fix(keyboard): decide the doubled-value warning from applied, not the reason's name
`applied` was destructured away and never read, so the note's doubling warning
came from a set of reason NAMES. That answers "nothing was written" for the two
shapes that arrive from outside the table:

  - a reply carrying `applied: true` with no reason at all — `reason` is
    optional on `SetTextResult`, and this is the one shape the `??` fallback
    exists for. It was labelled `action_refused`, so the caller was told the
    widget refused AND lost the warning that the fallback is about to write on
    top of a value the tool itself put in the field.
  - a reason from a helper newer than this build, which renders as "a reason
    this version does not recognise" and was likewise treated as a refusal.

The unnamed miss is now `value_mismatch`, which is what it is: the widget took
the action and the field read back holding something else.

The shared mock derives `applied` the way the helper does, so a case that names
a reason cannot state it wrongly.
2026-08-20 21:06:04 +02:00
Hubert Gancarczyk 1d7f7a4d06 fix(keyboard): read the setText reason as an own property, not through the prototype
`reason` crosses the RPC boundary as a free string from a helper that may be
newer than this build, so `WHY[reason] ?? …` resolved an inherited key rather
than falling back: `constructor` rendered `function Object() { [native code] }`
into the note, and `toString` and `hasOwnProperty` the same way.

The guard was added so an unrecognised reason does not read as "(undefined)";
these three read worse than that. Same own-property check, for the same reason,
as `resolveAndroidNamedKeycode`.
2026-08-20 20:56:51 +02:00
Hubert Gancarczyk a35d843e0b refactor(android-devtools): drop the setText reply's length field
The helper stopped sending it: on a match it was the REQUEST's length - the
caller's own string - and -1 on every other outcome, so no input made it say
anything. This side only ever destructured `{ matched, reason }`.

The two halves are one wire contract, so they move together with
argent-private's "drop the setText reply's length field".

Also names the password case on `unverifiable`, now that the helper reports it
there rather than as `value_mismatch`. A password field publishes one bullet
per character instead of its value, so a non-empty write can never read back
equal to itself; clearing one still verifies, because empty is empty whether or
not it is masked.
2026-08-19 13:32:21 +02:00
Hubert Gancarczyk 8ef553d55f test(keyboard): make the setText mock's length match the helper
The shared mock returned `text.length` for its `matched: false` cases too, a
reply the helper no longer produces. Nothing asserted on it, so this is
fidelity rather than a fix - but a mock that can return what the real thing
cannot is where a future assertion goes wrong.
2026-08-18 20:24:09 +02:00
Hubert Gancarczyk 7df9ed6ef5 feat(keyboard): clear Android fields atomically 2026-08-18 17:10:41 +02:00
Hubert Gancarczyk 75e4e04c08 docs(flow): stop offering two clears a TV cannot do, and an assert that does not exist
`flow-yaml.md` ended its three-TV-family paragraph with "Delete an existing value
with repeated backspaces, or use the field's own clear affordance". The `keyboard`
tool rejects `key` outright on a TV target, so repeated backspaces are available
on Vega alone; on Apple TV and Android TV the field's own affordance is the only
way. Both halves were offered to all three families.

The clear-only parse test justified itself with "empty the box, then assert the
empty state", but there is no such assert: `equals: ""` and `contains: ""` are
rejected at parse time and `matches: '^$'` never fires, because absent or empty
text is not a haystack — as `references/asserting-field-values.md` already says in
words. The comment now names the reasons a clear-only step is legitimate on its
own, and points at that reference for what can actually be asserted afterwards.
2026-08-17 15:22:54 +02:00
Hubert Gancarczyk 2e508c02d9 fix(flow): report a type step cancelled mid-Enter as a skip, not a pass
The post-dispatch abort re-check sits before the submit gate, so nothing stood
between the Enter dispatch and `return { ok: true }`. `dispatchOrAbort`
reclassifies only a dispatch that REJECTS under an aborted signal, and the
Android backend takes no signal at all, so a cancel landing mid-Enter left the
call to resolve and handed back `true`. The same cancellation window therefore
flipped a step between `pass` and `skip` on which of its two keyboard calls
happened to be in flight — and the `pass` arm is the one where the submit really
was sent after the caller gave up, which is what the re-check above exists to
prevent.

Driven through the real runner with a signal-less keyboard mock: `{ text }` with
`submit` defaulting on reported `type:pass` with both dispatches issued, and
reports `type:skip` now. An explicit `submit: true` clear-only step, where the
Enter is likewise the last dispatch, is pinned alongside it.

Also corrects two claims the same block makes:

- the backends that take no signal. Chromium now honours one, so Android is the
  only one that takes none — and the two that DO only narrow the window rather
  than close it, since each checks between keystrokes.
- the `runType` parenthetical's premise. `scrollIncrement` is not "one call per
  step": a `scroll-to` calls it up to MAX_SCROLL_ITERATIONS times through
  `scrollToVisible`, which guards the signal at the head of every iteration
  itself — so the dispatch is safe to leave bare, but not for the stated reason.
2026-08-17 15:22:54 +02:00
Hubert Gancarczyk d91bf6ee51 fix(flow): report a cancelled clear-only step as a skip, not a pass
`dispatchOrAbort` reclassifies only a dispatch that REJECTS while the
signal is aborted, and the Android and Chromium keyboard backends take
no signal at all — so a cancel landing mid-call leaves the call to
resolve and returns `true`. The re-check that catches that lived inside
the `if (step.submit ?? step.text !== undefined)` block, which is false
for a clear-only step, so the two shapes diverged: cancel a run during a
`{ into, clear: true }` step's one dispatch and it reported `pass`, while
the identical cancellation on a step with text reported `skip`.

The check moves out of the submit gate. The submitting path is unchanged
— it still runs before the Enter dispatch — and the clear-only path
gains it.

`flow-abort.test.ts` grows the twin of its existing clear-only case: the
existing one mocks the keyboard to throw, which `if (!sent)` already
caught; this one resolves inside the cancelled window, and asserts the
clear DID go out so it cannot pass by the step never reaching the
keyboard. It fails with `type:pass` before this change.

Two review findings on the same file that are documentation, not
behaviour:

- The `FocusOutcome` docblock named the FOCUS_REPORTING_SOURCES guard as
  a producer of "unobservable". No flow can reach it: `type` runs only on
  ios, android and chromium, and each hard-codes a source already in the
  set. It is kept as a forward guard — a new platform would otherwise
  poll the full timeout on every type step — and the docblock now says
  which of the two producers is live.
- `focusedFromInside`'s containment conjunct was pinned by nothing: every
  fixture pairing an out-of-target focused node with a small enough frame
  is refused by the namesake counts first, so dropping `frameWithin`
  reddened no test. The new fixture puts a same-`resource-id` sibling
  outside the container's box, where both counts hold and containment is
  the only thing left to refuse on — the ordinary Android shape, since a
  resource-id names the layout slot. Verified by mutation: dropping the
  conjunct now fails that test and only that test.
2026-08-17 15:22:54 +02:00
Hubert Gancarczyk da61986dec test: pin this round's fixes, and the gaps the review's sweep found
New coverage for each fix, every one of them mutation-checked (revert the fix,
the named test goes red):

  - an editing host holding identified or password content beside the named
    paragraph refuses, while the bare `<div contenteditable><p>…</p></div>`
    still clears;
  - the tapped element unmounting while a namesake holds focus refuses, and
    the same page with it mounted still clears;
  - the three ways a focused node borrowed another element's evidence — a
    namesake the tap creates, a container that grows onto a field the tap never
    reached, a focused field that renames itself out of the namesake set — plus
    the control that keeps the name;
  - a slow-but-answering tree source is not reported as an outage;
  - the chromium walker keeps an invisible element that holds the caret (and
    one that contains it), still prunes when the caret is elsewhere, never
    resurrects a display:none subtree, and keeps a box-less focused node.

And the gaps the sweep listed:

  - no test parsed a non-object `type` body, so the message could name one of
    the two valid shapes and stay green;
  - no test covered a non-abort `gesture-tap` rejection in `runType`;
  - nothing bounded the focus window from ABOVE;
  - the skill docs' `clear` YAML examples were outside the parse guard's slice;
  - `carriesFocus`'s recursion fixture put the flag at depth 0, where a
    non-recursive check reads the same — give the shadow wrapper an id;
  - four weak assertions: `toBeDefined()` on a `findById` that returns null,
    an `?.identifier` that reads undefined when nothing resolved, a `toBe`
    between two strings that pass as `"" === ""`, and a flat-leaf invariant
    omitting `focused`, which is now producible.

Also drops the wall-clock half of the early-exit test: `elapsed < 3000` timed
the whole step — temp dir, device resolution, the settle, two keyboard
dispatches — so it measured the host and failed at 3066ms and 3388ms under
load, while the read count beside it pins the exit exactly at any speed.
2026-08-17 15:22:54 +02:00
Hubert Gancarczyk 2b9e7b8de8 fix: the LOW findings around the gate, the parser and the walker
- `focusedFromInside`'s trailing `underTap.every(...)` is unreachable as false
  once the `controls` rule holds, so drop it and state why it is implied.
- A `type:` step with no `into` rendered its entry as the bare word `undefined`,
  which in a multi-step flow does not say which step is broken. Catch it in the
  type arm and echo the step, the way the `claer` typo path already does.
- The two clear refusals blamed an overlay or a sibling for what is routinely a
  field OVERHANGING the container the selector names — default content-box
  sizing does it for a few pixels, and neither message admitted the
  possibility. Reproduced and re-read on Chrome 151.
- `chromium.ts` claimed the focus flag reaches every describe consumer:
  `hasContent` does not list `focused`, so a node whose only signal is focus is
  dropped from the agent-facing render. Verified — an anonymous empty focused
  contenteditable is absent, the same editor holding a `<p>` is shown.
- It also claimed focus inside an iframe leaves the tree with no flag at all.
  True in the light DOM; inside an OPEN shadow root the HOST is flagged, since
  the excluded iframe is the root's activeElement. Verified both ways.
- The zero-frame sibling drop now consults `focusedSelf` like the two
  structural collapses beside it — same class of drop, same consequence.
2026-08-17 15:22:53 +02:00
Hubert Gancarczyk 7076b5f0f8 test: pin every fix in this round, and the gaps the review's sweep found
Each of these was verified by mutation: apply the pre-fix code, the named test
goes red; revert, green. Thirteen mutations, thirteen killed.

New coverage for the fixes:
- a focus read that HANGS past the window rather than throwing (H1), asserting
  both the outage refusal and that the step answered while the read was still
  outstanding - the bound is what keeps "within 3000ms" true;
- a container with a GAP down the middle, once with two controls and once with
  one (H3), so what decides is the number of controls and not the gap;
- a focused wrapper around one anonymous FIELD (M3), the editing host's twin;
- `type: { clear: true }` with no `into` as a validation failure, not a crash (L1);
- a replaced getRootNode still reporting focus (L3), and a page describing in
  full with ShadowRoot set to a non-constructor (L5).

And the gaps the review named:
- T1 the enclosing-pad sweep now starts at 1px, where the defect lived;
- T4 an UNIDENTIFIED field's draft hoisting into its container's text and
  out-ranking a real control for a tap - the three tests beside it all give
  their field a placeholder or an id, so they pinned the shielded case while
  reading as proof of the general property;
- T5 the new asserting-field-values reference gets a parser guard: `equals: ""`
  and `contains: ""` really are rejected, `matches: "^$"` really does parse;
- T6/T7 the ambiguity fixtures now exercise the gates they name. The anonymous
  row put focus on the field the tap MISSED, which later gates refuse on their
  own, so both arms passed with the ambiguity gate deleted; it now differs in
  the colliding name alone, and the same-id re-layout gets the tree order where
  "first match wins" would hand back the focused row.
2026-08-17 15:22:53 +02:00
Hubert Gancarczyk dff8420aec fix: the LOW findings around the focus read, the walker and the parser
L1 - `type: { clear: true }` with no `into` crashed with "Cannot read
properties of undefined" instead of failing validation: JSON.stringify returns
undefined for undefined, and badEntry read .length off it. The crash predates
the branch, but `clear` in the allowlist is a new door onto it - at the merge
base the same flow failed cleanly as an unknown key. Both `clear` and `text`
forms now return FLOW_ENTRY_UNRECOGNIZED naming `type.into`.

L3 - a page that REPLACES Element.prototype.getRootNode (ShadyDOM,
webcomponents.js) hands back an object that is neither a ShadowRoot nor the
document, and the focus read answered null for it: verified on Chrome 151, the
flag vanished from every node on the page, which is the no-focus tree a
destructive clear goes through on. It falls back to the document's
activeElement - the read this had at the merge base. Focus survives the
replacement now, and an open shadow root still flags the inner element only.

L4 - the iframe descent is dead in a renderer (walk() bails on an inner
documentElement, whose constructor belongs to the inner realm), so it is gone
along with the file header's claim to walk subdocuments. Verified before and
after: an <iframe srcdoc> leaves no trace either way. Its only test passed
because the harness is single-realm, so it went too (T8).

L5 - `typeof ShadowRoot === "undefined"` does not cover a page assigning a
non-constructor. With window.ShadowRoot = null, ShadowRoot.prototype threw
before a single node was walked and describe failed for the whole page
(reproduced). Both that read and the instanceof test now require a function.

L6, L7, L8 - three comments corrected against what the code does: preTapTree is
the last read that SUCCEEDED and can predate the tap by a failed read's whole
timeout; the plain-type early exit deliberately does not fire on a tree that
flags focus nowhere, so that shape still pays the full wait; and no flow report
is written to disk - the reason travels to stdout and the agent's context,
which is reason enough to name no element text.
2026-08-17 15:22:53 +02:00
Hubert Gancarczyk 3385468cbd fix(flow): size the containment test exactly, so no pad hides inside its slack
frameNoLargerThan carried the same per-dimension slack frameWithin applies per
edge, so a focused node padded symmetrically around the target by half the
threshold satisfied both and took the clear. On a 900px window that is a trap
one pixel larger on every edge: reproduced on Chrome 151, where a focusin trap
holding DRAFT-do-not-erase over an <input> passed the step, was emptied and
rewritten with the new address, and left the named input holding its old value.

There is no tolerance that fixes it. A trap enclosing the field by 1px and a
child overhanging its wrapper by 1px are the same geometry, and the flow trees
carry no ancestry to tell them apart - flattenHoisting returns leaves under one
root on every platform. So the size test is exact: equal frames still confirm
(the everyday wrapper-around-input shape, verified live), and any overhang
refuses, which fails the step loudly instead of emptying a field the step never
named.

The overhang sweep moves with the contract: pad 0 is now the admitting case and
1-3px refuse, where the 1-2px arm previously pinned the tolerance this defect
lived in.
2026-08-17 15:22:53 +02:00
Hubert Gancarczyk 2b77ccf42a test: close the gaps the review's mutation sweep left open
Every pin below was mutation-checked: the pre-fix code was restored in
`flow-actions.ts` / `chromium.ts` and each named test went red.

- `sameElement`'s LABEL fallback had no coverage, and it is the whole
  name-is-not-an-identity defect. Every fixture in the runner file gives its
  nodes a resource-id, so the anonymous path — the everyday React Native and
  plain-DOM shape — never ran. `anonymousRowXml` runs it both ways: a namesake
  and a differently named sibling.
- `trackTarget`'s proximity tie-break had none either. `twoSameIdRowsXml` is the
  re-layout that made it adopt the wrong namesake.
- `focusedOwnsTargetText` needed the trap whose OWN text equals the target's;
  the existing editor fixture only ever exercised the arm that should confirm.
- `requireEvidence`'s safety half was unpinned AND the test written for it did
  not reach it: `overlayFocusXml` carries no `email-wrapper`, so every overlay
  read was consumed by selector resolution before the tap went out and the focus
  poll's first read already confirmed. `overlayOverWrapperXml` carries the
  wrapper, so the clear really has to poll past an overlapping read.
- The textarea change had no coverage past the walker itself. Four cases now run
  the real walker THROUGH the real flow adapter and matcher: the container
  hoist, the page-wide `visible` check, the tap ranking, and the clear-only
  proof that still has to work on an unlabelled composer — plus an
  `<input>`-vs-`<textarea>` spelling case.
- `describe-chromium-script.test.ts` certified behaviour that cannot happen: it
  asserted focus inside a same-origin iframe is carried by the inner element.
  The walker never descends — `el instanceof Element` rejects the inner
  `documentElement`, whose constructor belongs to the OUTER realm — and the mock
  passed only because it is single-realm. Verified live on Chrome 151: an
  `<iframe srcdoc>` with a unique marker leaves no trace in describe, and
  focusing the inner input leaves the tree with NO focus flag at all. That is
  the "unobservable" state a destructive clear dispatches on, so the test now
  pins it as such.
- The refusal tests ran at ~75% of vitest's 5000ms default (measured
  3757-3760ms) on the one file whose names would make a load-induced timeout
  look like a real destructive-clear regression. They get their own testTimeout.
2026-08-17 15:22:20 +02:00
Hubert Gancarczyk 991b228256 fix: four LOW findings around the clear's focus wait and the shadow-root read
- A page that removes `Element.prototype.shadowRoot` took describe down
  entirely. `protoGetter` falls back to a direct property read when the
  descriptor is absent — this file's own documented threat model — so a `<form>`
  holding `<input name="shadowRoot">` hands the CONTROL element to the new
  ShadowRoot `activeElement` accessor, and Blink throws `Illegal invocation` for
  the whole page. Verified live: `CHROMIUM_DESCRIBE_FAILED` for every element
  until the descriptor was restored, and clean with the `isShadowRoot` guard the
  same function already applies to `root` two lines down. The base could not
  throw here — it only ever read `shadow.children`. The mock ShadowRoot's
  accessor is brand-checked now, so a test can see this at all.

- A focused node whose frame EQUALS the target's was classified as "encloses".
  `frameWithin`'s slack is per-edge, so identical frames satisfy it, and the
  refusal then tells the author to point the selector at the input itself when
  it already does. `frameNoLargerThan` — added by this PR for exactly this
  distinction — is now consulted here as it is in `focusedOwnsTargetText`.

- The focus wait could overrun its stated budget by a whole tree read. The
  deadline was checked only AFTER `fetchFlowTree`, while the sleep is capped to
  land exactly on it, so one more read always started past it — 1-2s of a
  `uiautomator dump` beyond the 3000ms every refusal message claims the window
  was.

- The `FlowStep` union admitted a `type` step with neither `text` nor `clear`:
  it serializes to YAML the parser rejects, labels itself `⇐ (clear only)` in a
  recording summary, and dispatches no keyboard call at all — a silently passing
  no-op. Pairing the two fields in the union keeps the parser the only thing
  that can build one.

The remaining LOW findings need no change: a labelled `<textarea>`'s 200-char
truncation and its live contents feeding the settle fingerprint both went with
the textarea fix, which stops the contents becoming node text at all.
2026-08-17 15:22:20 +02:00
Hubert Gancarczyk 645771dd55 fix(describe): spell a <textarea>'s contents the way the field holds them
`accessibleName` whitespace-normalized a `<textarea>`'s value where the
`<input>` path deliberately did not, so the same typed text was assertable back
in one element type and not the other: `equals: "  hello  "` matched at the base
and stopped matching, and a two-line value came back spelled as one line while
the identical string in an `<input>` came back raw. Nothing warned an author
which spelling applied to which element.

The normalization was there because the walker also emitted the value as node
text, and two spellings of one value cannot both be quoted by a failing assert.
The contents now reach the tree as exactly one string, so that string is the one
the field holds. Verified live: an `equals` on `"  hello  "` passes against a
textarea and an input alike.
2026-08-17 15:22:20 +02:00
Hubert Gancarczyk c46eb09844 fix(flow): bound the focus wait's dark tail, instead of failing open on it
`lastRead` is only ever written by a read that SUCCEEDED, so the "unreadable"
outcome fired only when EVERY read in the window threw. One successful poll
disarmed it permanently — including the first, which is the one most likely to
report "no focus yet", because the app's focus round-trip has not finished. That
maps to "unobservable", the one non-confirmed outcome `runType` deliberately
lets a destructive clear through on. A transient tree-source outage in the tail
of the window therefore converted a correct refusal into a blind clear.

Reproduced on Chrome 151 against this branch's tool-server: nothing focused at
the first poll, the tree read poisoned from t+600ms, focus landing on `#other`
at t+900ms where no read could see it. The step passed and wrote `WIPED` into
`#other`, a field it never named; the same page with the reads left alone
refused, and so did the same page poisoned before the first read.

The file already states the rule 700 lines up — `CONDITION_DARK_TAIL_TOLERANCE_MS`,
"a verdict narrated from the reads before the darkness would describe a screen
nobody saw at the deadline". This applies it to the one place where that stale
verdict authorises a destructive action, counted in consecutive failed polls
rather than in milliseconds: one poll costs a whole `uiautomator dump` on
Android, so an elapsed-time budget would read a slow device as an outage. One
failure is still the last-poll blip the tolerance exists to absorb.

The refusal now says the tree stopped answering rather than that it could not be
read at all, which was only ever true of the every-read-failed case.
2026-08-17 15:22:20 +02:00
Hubert Gancarczyk 9743fbfcaf fix(flow): stop a read that cannot SEE focus erasing one that found it
The focus wait classified an empty `focused[]` as the observation "no-focus",
which `giveUp` maps to "unobservable" — the one non-confirmed outcome `runType`
lets a destructive clear through on. `lastRead` is latest-read-wins with no
floor, so a single read in which the focused element's leaf is simply MISSING
erased everything the earlier reads had established. Nothing throws; the tree is
complete and still holds the target. Both flow adapters produce exactly that for
a frame clipping to zero area, which is what keyboard avoidance does to a field
it scrolls away, and on Android what a healthy `uiautomator` dump reports for a
focused view whose bounds have collapsed.

Reproduced on Chrome 151 against this branch's tool-server: `#other` holds the
caret and never loses it; the tap on the target does not move focus; collapsing
`#other`'s box 1500ms into the wait took the step from "focus never reached" to
a pass that emptied `#other` and wrote `WIPED` into it — with `#other` still
`document.activeElement` at the end. The same page without the collapse refuses.

So a read that saw nothing no longer overwrites one that saw where focus is.
Latest-read-wins still decides between sightings, which is what keeps a blur on
the focusing tap from being answered with history, and a window in which NO read
ever saw focus is still "unobservable" — verified live that a page with nothing
focused still dispatches the clear, which is what keeps the directive working on
an iOS build whose injected framework omits `firstResponder`.

The residual `runType` documents narrows accordingly: an app that blurs, or a
field that scrolled away, now only reads as unobservable when it happened before
the first read.
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk 85fa54a06a fix(describe): stop a <textarea>'s draft reading as text the page displays
The walker gave a `<textarea>` its live `el.value` as the node's own text. That
fixed the real problem it was aimed at — child text is the markup DEFAULT and
never tracks the value — but node text is what the page DISPLAYS: it hoists into
an ancestor's `subtreeText`, a `text` selector matches it, and the resolver
ranks an exact field match on it above a substring hit. A draft sitting in a
composer therefore behaved like text the page was showing.

Measured on Chrome 151 against this branch's tool-server, each against the same
page with only this hunk changed:

  - `assert: { text: { in: { id: chat }, contains: "hello team" } }` PASSED with
    the message list empty and the draft still in the box, so a saved regression
    test written that way can never go red;
  - `assert: { visible: "Alpha" }` PASSED on the composer holding "Alpha" — the
    very "assert the consequence instead" pattern the new reference prescribes;
  - `tap: { text: "Save" }` landed in a note whose draft was the word "Save",
    not on the Save button, whose label is the only "Save" on the page.

A field's contents are not the page's text and there is no third channel to tell
them apart in, so a `<textarea>` now reads its contents back exactly as an
`<input>` does: through `accessibleName`'s value fallback, which an aria-label
or a placeholder pre-empts. All three repros are refusals again, a `tap` lands
on the button, and the clear-only proof still works on an unlabelled composer
(`hidden` fails before the clear, passes after).

Two MEDIUMs fall out with it: a >200-character value can no longer be reported
twice (the dedup compared an unsliced text to an already-sliced name), and a
labelled textarea's contents no longer reach a failing assert's quoted text, so
a secret typed into one stays out of the flow report.
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk 591c8ea4fd docs: correct nine claims this PR's own code or platforms disprove
Comments and skill pages that describe something other than what happens. No
behaviour change.

- `live-authoring.md` told an agent to KEEP a recorded `tool: keyboard` clear
  raw, on the false premise that `type:` accepts only `into`/`text`/`submit`
  (`flow-utils.ts` allowlists `clear`) and pointing at a table exception this PR
  deleted. It is the raw form that is unsafe: it replays with no focus tap and
  no focus check. Converting it is the whole mitigation, and that paragraph
  said not to.
- The same page claimed an unconfirmed poll falls through to typing. It does
  for a plain `type`; a `clear` hard-fails, which is what `flow-yaml.md`
  already says.
- `dispatchOrAbort`'s docstring said `keyboard` discards the `ToolContext`. All
  three platforms `clear` supports forward the signal and check it between
  keypresses, so an abort-driven rejection is the designed common case there —
  which is what makes the wrapper load-bearing rather than defensive.
- The `requireEvidence` fast path was justified by uiautomator flagging a
  WebView instead of the EditText inside it. Surveyed across eight screens on
  API 36 / WebView 151, including a real in-app WebView, the WebView is
  `focused="false"` and the leaf EditText carries the flag. The exit stays; its
  stated reason does not.
- `FOCUS_REPORTING_SOURCES` said a source outside the set never reports focus,
  naming Vega. Vega's source-parser does set it and `flow-vega-tree` preserves
  it; it is excluded because `runDirective` refuses `type` there first.
- The Android TV comment had a `clear` step failing at the first keyboard
  dispatch, which presupposes the focus gate passed. `android-devtools` is in
  the reporting set, so the wait reports "unconfirmed" and the refusal returns
  before any dispatch — as `flow-yaml.md` says.
- `flow-run.ts` cited `(down)` as a sibling option echo twenty lines above the
  branch that suppresses exactly that value.
- `asserting-field-values.md` described the textarea case as either/or, gave an
  over-broad rule for when an `<input>` exposes its value (`accessibleName`
  gates on truthy/resolving, so `aria-label=""` still falls through), and
  recommended `assert: { hidden: … }` as working on every platform when it
  passes vacuously on exactly the platforms where the contents never reach the
  tree. It now also states the secret exposure a `<textarea>` carries.
- `flow-keyboard-clear.test.ts`'s header still gave the allowlist as the reason
  the round trip is worth pinning. The round trip is; that reason is not.
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk d1e28a412d test(flow): pin the shapes the static fixtures could not express
Every focus fixture held the tree constant across polls and split its rows
unevenly, which is what let five defects through a suite that was otherwise
green. Each test below fails against the code as it stood one commit before its
fix, and each ships with the control that keeps the fix from being a blanket
refusal.

- an EVENLY split row whose focused half is not the one the tap hit refuses,
  and the same row with focus on the half the tap DID hit still clears;
- a focused input overhanging its wrapper by 1-2px still clears, and 3px — six
  pixels of extra width on a 1080px screen — refuses, which pins both sides of
  the containment epsilon instead of only the far one;
- an overlay appearing over the tap point AFTER the tap refuses, and the same
  overlay already there at tap time still clears;
- a named container that GROWS between the tap and the poll still clears;
- a selector that re-resolves to a better-ranked node after the tap still
  clears;
- a rich-text composer whose only text sits in a child node clears, and the
  same editor holding a second paragraph refuses;
- a `<textarea>` holding a newline reports its contents once, not once raw and
  once normalized.

Also pinned, all three of which a mutation survived: a `clear` keeps polling
past an intermediate overlap read instead of refusing on it (the safety half of
`requireEvidence`, whose perf half was already covered), the focus flag is
suppressed on a shadow host when the flag sits NESTED inside its subtree rather
than one level down, and no refusal reason quotes the focused element's own
text — the invariant that keeps a field's value out of a report on disk, until
now asserted only in a comment.
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk fd4aa70e56 docs(test): drop the append model from the two tests still teaching it
`1ab12bd2` corrected "typing appends" everywhere in this branch except
here. The test NAME - which is what shows up in suite output, so it is
read outside the file - said "instead of silently appending", and its
comment "an append, which fails much later at an assert". A third copy in
`flow-tools.test.ts` glossed `←` as "(append)" while documenting a
production comment that says the opposite: `flow-finish-recording.ts`
renders it as "types into whatever is already there - NOT necessarily
appending".

The measured behaviour is per-platform: on Android and Chromium the
focusing tap lands the caret mid-field and the new text is spliced INTO
the old value; only on iOS does the caret jump to the end and append.
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk 6552874e66 fix(describe): give a textarea its LIVE value, not an empty string
`2f8d2ffe` stopped a `<textarea>` emitting its markup default - correct,
since child text is the authored content and never tracks `el.value` -
but did it by dropping the text entirely, on the grounds that "the
accessible name already carries the live value". That only holds for a
textarea with no aria-label, no aria-labelledby and no placeholder:
`accessibleName` returns those first and only then falls through to
`el.value`. With any of them the node carried NEITHER - the name was the
label and the text was "" - so the field's contents reached no describe
consumer at all.

Reading `el.value` fixes both: the live contents are what the field
shows, which is what every other element's own text already means, and
an unlabelled textarea still de-duplicates against its name exactly as
an `<input>` does.

On Chrome 151 through the branch tool-server:

  textarea "Notes" id="t1"                     <- before, contents gone
  textarea "Notes" value="initial content one" id="t1"   <- after

  assert { text: { in: { id: t1 }, contains: "initial content" } }
    before -> fail, 'its text was "Notes"'
    after  -> pass

and the case `2f8d2ffe` exists for still holds: clear + type "final" on
`<textarea id=t3>initial content three</textarea>` leaves an
`equals: "final"` assert passing, with the default gone.

The test added with that commit exercised the labelled case specifically -
the one where nothing was left behind - and could not see it, because the
mock built a plain element that the script's `instanceof
HTMLTextAreaElement` branches never recognised.
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk e163ff6114 fix(describe): stop a collapsed element taking the focus flag with it
The focus block ran at the END of walk() - after both collapse returns -
so an element the walker collapses never reached it. The host was already
suppressed on the assumption that the inner element would carry the flag,
and when the shadow root's activeElement was collapsible BOTH candidates
disappeared and the tree carried no focus at all. `runType` reads that as
"nothing was focused", the one non-confirmed outcome it dispatches a
destructive clear on.

The collapsible-and-focusable shape is the standard rich-text composer: a
bare `<div contenteditable><p>…</p></div>`, which is what Quill /
ProseMirror / Lexical render on a single-paragraph document - box-less,
one child, no id, role or own text. Put it in an open shadow root (any
Lit/Polymer component) and both halves apply at once.

Two changes. The flag is computed ahead of the collapses and keeps the
node alive like a name or an id does - being the caret's element is
targeting information in its own right. And the host is suppressed only
once its shadow subtree has ACTUALLY produced the flag, so a pruned or
collapsed inner element leaves the host reporting rather than nobody.

On Chrome 151 with a `focusin` trap holding the shadow editor:

  1 paragraph, before -> pass, editor's draft replaced, #q untouched
  1 paragraph, after  -> describe shows `div [focused]` inside #shost,
                         step fails, both intact
  2 paragraphs        -> refused either way (the editor survives collapse)
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk e206645e76 fix(describe): route the focus reads back through the captured getters
`getActiveElement` was captured for a stated hazard - Document's named
getter is [LegacyOverrideBuiltIns], so a `<form name="activeElement">`
shadows the property - and then referenced nowhere. `root.activeElement`
is a raw read, and `getRootNode()` returns the Document for every
light-DOM element, so for the whole page that WAS a raw
`document.activeElement`. `el.shadowRoot` was read raw too, three lines
from the descent that goes through `getShadowRoot` under a comment naming
that same hazard.

On Chrome 151 through the branch tool-server, with a
`<form name="activeElement">` wrapping the fields:

  document.activeElement (raw)   -> FORM#signin
  Document.prototype accessor    -> BODY  (nothing focused)
  describe                       -> form id="signin" [focused]

and `type: { into: { id: email }, clear: true }` on that page hard-failed
with "the only element reporting focus CONTAINS id=email" - a focus trap
that was not there. After the fix the form is not flagged and the clear
passes, replacing #email.

The Document read now goes through `getActiveElement`, the ShadowRoot
read through a `ShadowRoot.prototype` accessor captured the same way, and
the delegation check reuses the `shadow` the descent already captured.

Also pins the hunk `7323eb45` added, which nothing reached: the mock DOM
defined no Document constructor (so protoGetter degraded to direct reads),
no ShadowRoot, and no `getRootNode`, which made both halves of the focus
block unreachable from a test. Three single-line mutations now fail -
reading the document instead of the element's own root, `delegates =
false`, and the raw `root.activeElement` - where all three left the suite
green before.
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk 7af7161e14 fix(flow): stop the containment slack admitting an ENCLOSING focused node
`frameWithin`'s epsilon is per-EDGE, so a frame overhanging the target by
under the tolerance on EVERY side satisfies "sits inside it" while being
a full epsilon larger in each dimension - which is the `encloses` shape
the same file refuses as "not evidence", and gives `clearRefusalReason`
its own arm to refuse. A focused `android.webkit.WebView` clearing its
target by 4px on a 1080x1920 screen passed containment and took the clear,
with the step reporting a pass on the field it never touched.

The confirm arm now also compares EXTENTS: a node that stands for the
target is not bigger than it. That catches a symmetric pad at half the
per-edge threshold while still admitting the overhang the epsilon exists
for (a child a border's width wider than the box it sits in).

The docblock's rationale was wrong too - it described absorbing
disagreement between "three different adapters", but both frames come out
of one tree read by one adapter, so there is nothing cross-adapter to
absorb. It now gives the reason that applies, matching the identical
`WITHIN_EPS` in `ui-tree-match` and `EDGE_EPS` in this file.

Pinned at pad 4 / 5 / 12 / 100: the guard's removal fails 4 and 5, and
widening FRAME_CONTAINMENT_EPSILON to 0.05 fails 4, 5 and 12 - the value
was previously unpinned in either direction.
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk 0d7a50d457 perf(flow): stop a plain type polling for a verdict it never reads
Only "confirmed" returned early, and only a `clear` reads the outcome -
so a plain `type` polled to the full TYPE_FOCUS_TIMEOUT_MS on every shape
that base returned from immediately. Base ended the wait as soon as ANY
focus-flagged node overlapped the target, and the shape that lost it is
the ordinary hybrid one: uiautomator puts focused="true" on the enclosing
`android.webkit.WebView`, not on the `EditText` inside it, and an
enclosing node can no longer confirm. A WebView-hosted form of n fields
paid n x 3s on the path with no clear at all.

`waitForFocus` now takes `requireEvidence`. A caller that will not act on
the verdict stops at the first read that settles it; a `clear` polls on,
unchanged.

Pinned by a test that counts tree reads (3, not ~13) and asserts the step
finishes inside the focus budget - it fails at 3773ms with the early exit
removed. On Chrome 151 a plain type into a field under a focused-form
shape runs in 1.08s.
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk 2feb70f123 fix(flow): confirm a clear from the input the tap hit, not any inside the box
The containment arm accepted ANY focus-flagged node inside the target,
with no check that it was the node under the point `runType` just tapped.
Its only discriminator was a role test on the TARGET - "skip this arm when
the target is itself a text input" - and that vocabulary misses in both
directions against the roles the adapters really emit:

  - over-match, so a legitimate wrapper clear is REFUSED. Material's
    `TextInputLayout` is the non-editable wrapper carrying the app's
    resource-id, and `deriveUiAutomatorRole` matches "textinput" on the
    short class name, so it derives `TextField`; ARIA 1.1 puts `combobox`
    on the wrapper around the input, which is in the anchored group.
  - under-match, so an overlay is ADMITTED. A `contenteditable` composer
    is role `div` and React Native's iOS host views come through as
    `AXStaticText`, so the arm fires for exactly the shape it was scoped
    to exclude.

And on a container holding more than one input - a currency/amount row,
an OTP row, a card number/expiry/cvc row - there was no discriminator at
all: the step cleared whichever sibling reported focus and passed on the
container.

The arm now asks whether the focused node covers where the tap LANDS (the
centre of the target's current frame, recomputed per round so keyboard
avoidance cannot stale it). Structure would be sharper, but both the
Android and the iOS full-hierarchy adapters flatten to leaves under one
root, so a wrapper and its input arrive as siblings and no ancestry is
available. `TEXT_INPUT_ROLE` / `isTextInput` are gone.

Reproduced on Chrome 151 through the branch tool-server, all four before
and after:

  composer + focused #mention overlay -> was pass, #mention overwritten
                                      -> now fail, both intact
  #amount-row over currency + amount  -> was pass, #currency overwritten
                                      -> now fail, both intact
  role=combobox wrapper               -> was fail ("a focus trap")
                                      -> now pass, replaced
  plain div wrapper (control)         -> pass either way
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk 24d8b73d49 fix(flow): resolve a type step's two halves with one ranked resolver
`flowSelectorToNode` picked its node with a bare `firstInReadingOrder`
while `flowSelectorToFrame` went through `selectorToFrame`, which RANKS
matches (exact field hits first, then smallest area). The two feed
opposite halves of one `type` step: the frame is what gets TAPPED, the
node is what the focus check judges identity against.

On the everyday label-above-input shape they disagree. A `{ text: Email }`
selector matches the label as a substring and the field exactly, so the
tap landed on the field while the identity check resolved to the label -
and could then never match. A `clear` hard-failed with "focus never
reached ...", sending the author after a selector that was already
resolving to the input; a plain `type` burned the whole focus timeout on
every such step.

Reproduced on Chrome 151 through the branch tool-server, `<div id=hdr>
Search results</div>` above `<input id=q aria-label=Search value="OLD
QUERY">`: the no-clear control typed into `#q` and passed in 4.8s, the
same selector with `clear: true` failed and left `#q` untouched. After
the fix the clear passes and `#q` reads "REPLACED", and the control drops
to 1.3s.

`selectorToFrame` is now `selectorToNode(...)?.frame`, so there is one
ranking to keep in step instead of two.
2026-08-17 15:21:57 +02:00
Hubert Gancarczyk 035afe4bb4 fix(flow): judge focus by identity, and flag the element that really holds it
Review drove two more shapes through the branch build against Chrome 150,
in opposite directions.

A focused node INSIDE the target's frame was accepted as proof, so a
suggestion popover overlaying a composer had its own field cleared and
overwritten while the step reported a pass on the composer it never
touched (`#body` byte-identical, `#mention` replaced, 3/3). Only the
enclosing direction was guarded.

And with focus inside an open shadow root `document.activeElement` is the
HOST, so the describe walker flagged the host - which contains the inner
input by construction. Naming the shadow's own input therefore failed
with "the only element reporting focus CONTAINS it", advice the flow was
already following, while the `keyboard` tool it was about to call handles
that shape correctly. Naming the host instead passed and cleared a
different child.

So the walker now flags the activeElement of the element's own ROOT, not
its document, and excludes a host that delegates into its shadow root -
one flag, on the element holding the keys. And the focus wait resolves
the target NODE, not just its frame: identity is confirmation, while
geometry is accepted only for a target that is not itself a text input,
which is what separates "the input inside the testID container I named"
from "an overlay sitting on the field I named". The two now behave:
`into: { id: sinput }` clears the shadow input and leaves its sibling
alone; the popover case fails and touches nothing; the focus trap and the
ordinary replace are unchanged.

An overlap that is neither is its own outcome with its own advice, rather
than being told it hit a shadow host.

Also here: the committed merge conflict in `argent-create-flow` (prettier
had rewritten the markers into a list bullet and a blockquote, so neither
git nor the formatter saw it, and the two surviving copies gave opposite
instructions about carrying a recorded Enter across); the TV rejection
reason, which stated a transport limit that does not hold for Android TV;
`runPinch` described as a single dispatch when it chains several; and the
per-platform assert table moved to `references/`, which is where the
skill guide puts reference material an agent consults rather than reads
every time.

Three invariants a mutation sweep found unpinned now have a test each,
each verified by re-applying the mutation: the `<textarea>` own-text
guard (which shipped with no test), the per-round target-frame
re-resolve, and the overlay-vs-wrapper distinction.
2026-08-17 15:20:53 +02:00
Hubert Gancarczyk ab7f3412b4 docs: correct the type directive's docs where the code stopped matching them
`(cleared)` in the recording summary is past tense for something that has
not happened: no step the recorder produces is a `type` — `flow-add-step`
emits only tap/launch/run/tool and the only `kind: "type"` construction in
`src/` is the YAML parser — so every `type` in a summary arrived by a hand
edit and did not run live. It reads `(clear only)` now, matching the run
report's present-tense `(clear first)`.

`type needs { into, text }` names one of two valid shapes since `clear`
made `text` optional, and it is the message an author sees after
extrapolating the bare-scalar form the other directives accept.

"`submit` serializes only the opt-out" stopped being true two lines below
itself, where a clear-only step serializes `submit: true`.

`dispatchOrAbort`'s docblock described a mechanism that does not exist —
cancelling a run does not tear down the transport under an in-flight call
— which made its real trigger look far broader than it is. It now says
what each of its two callers actually needs it for.

The per-platform caret split omitted Chromium in three places, which
matters because "appended" is the half that leads to leaving `clear` out;
re-measured, Chromium behaves like Android. The Android assert bullet
gained the password exception its Chromium sibling already carried — both
adapters redact identically, so `equals` on the typed value is false
either way. And `flow-ios-tree`'s "leaves the poll unconfirmed" named the
value that now refuses a clear, where the case it describes is the one
that must NOT.
2026-08-17 15:20:10 +02:00
Hubert Gancarczyk aafd9899bc fix(flow): require focus INSIDE the target before a clear, not merely overlapping it
The overlap test confirmed a clear by construction whenever a
focus-flagged node was large enough to cover the target, and every shape
that produces one can hide a different element holding the keys.
Reproduced on a live Chromium page through this branch's tool-server, an
ordinary `focusin` focus trap bouncing focus back to a `<textarea>` whose
box contains the target input:

    before: #notes = "DRAFT do not erase me"   #email = "old.remembered.login"
    - type: { into: { id: email }, text: "new@example.com", clear: true }
      -> pass, "into id=email (clear first)"
    after : #notes = "new@example.com"         #email = "old.remembered.login"

The user's draft is cleared and overwritten, the named target is never
touched, and the step reports a pass. The same shape occurs with an open
shadow root (`document.activeElement` is the host, never the inner
element) and with a hybrid app's focused WebView.

Confirmation now requires a focus-flagged node INSIDE the target's frame,
which keeps the case the overlap test was written for — a testID wrapper
whose input reports the focus. A node that merely encloses the target is
a new outcome of its own, refused with the reason that fits it. The same
run against the same page now fails with "the only element reporting
focus CONTAINS id=email rather than sitting inside it" and leaves both
fields alone; an ordinary field replace still passes.

A window in which every tree read threw is also no longer treated as
"nothing was focused". Nothing was OBSERVED, which is not evidence — the
convention `settleTree` already applies to the same condition — so it
refuses rather than clearing on it.

Also uniform now: all three of the step's device calls classify a
cancelled run the same way. The focus tap was bare while the two keyboard
dispatches were wrapped, so one step reported `error` or `skip` depending
on which dispatch was in flight when the caller gave up.

Four guards this stack added were removable with the whole flow suite
still green — the pre-Enter abort re-check, the clear/text dispatch's
return, the Enter dispatch's wrapper, and now the tap's. Each has a test
that fails when it is taken out.
2026-08-17 15:20:10 +02:00
Hubert Gancarczyk b7d68d767f fix(flow): judge focus by the latest read, not by any read that ever saw it
The focus outcome accumulated "saw focus on something" across every poll
round, which made the verdict a race. An app that blurs when the focusing
tap lands reports the PREVIOUS field as focused for however many rounds
precede the blur, so the same flow against the same app refused the clear
or passed it depending on whether round 1 beat the blur — one being a
hard step failure and the other a silent no-op.

The question the clear is about to act on is whether something else holds
focus NOW, so the outcome now reflects the most recent successful read.
An app that blurs lands in the documented residual deterministically, and
a target that never takes focus while another field keeps it is still
refused. A window in which every read throws still reports "unobservable"
rather than claiming focus was seen, and the type's doc no longer says
that bucket means only "the tree cannot report focus" — it also covers
"the tree could not be read".

Also records, where the decision is made, that the overlap test confirms
by construction against a focus-flagged node large enough to cover the
target (an Android host WebView, an iOS first-responder container, a
Chromium focus-trap div). On Chromium that is benign — one activeElement
per document, so a focused container means no input holds focus and the
clear no-ops. The destructive shape needs two focused nodes at once,
which only the Android/iOS adapters can emit; it has not been observed on
a real device, so it is documented rather than guessed at.
2026-08-17 15:20:10 +02:00
Hubert Gancarczyk 2b2ef6ea9b fix(flow): pin the abort/focus arms review found unpinned, and cut the tap wrap
Second review pass, all four items proven by mutation rather than reading.

Two arms of the new code were asserted by nothing. `dispatchOrAbort`
returning false on ANY error — turning every backend rejection into a
silent "run aborted" skip — passed all 3131 tests; `runRotate` already
carried that positive control, `runType` did not, so it has one now.
Likewise the focus-wait's source gate: mapping a non-focus-reporting
source to "unconfirmed" instead of "unobservable" passed everything,
while refusing `clear` on every target behind such a source — the same
platform-wide outage the outcome split exists to prevent. Both mutations
now fail.

The `gesture-tap` wrap is reverted. It was not needed by any of the
fixes, nothing asserted it, and it singled the focus tap out for a guard
its five siblings (runTap, runLongPress, scrollIncrement, runPinch) do
not have.

`runRotate` now shares `dispatchOrAbort` rather than keeping the inline
copy the helper was written from, and the helper's doc no longer claims
`runLaunch` as a precedent: `runLaunch` returns a failure outcome instead
of rethrowing, so it deliberately is not a caller.

The clear-only cancellation test keeps its case but drops the rationale
about reaching the dispatch "by a different branch" — after the collapse
both shapes take the same one.

Per-platform assert guidance corrected again: on Chromium an assert on
the typed value hard-FAILS like iOS (it reads the accessible name); it is
the label-matching assert that passes vacuously. The name also falls back
through aria-labelledby, skips password fields, and covers `<textarea>`,
and Android only pairs hint with value when a contentDescription is
present — without one, most React Native `TextInput`s report the contents
alone and `equals` is correct.
2026-08-17 15:20:10 +02:00