Commit Graph

1558 Commits

Author SHA1 Message Date
Michał Pierzchała e0cd06e567 test(snapshot): add cross-runtime presentation conformance (#1973)
* test(snapshot): add cross-runtime presentation conformance

* fix(ios): preserve acquired snapshot actionability

* chore(ios): retain existing XCTest selection name

* test(snapshot): cover nested cumulative clipping
2026-08-23 17:29:38 +02:00
Michał Pierzchała 6eb74d08ce feat(android): bound snapshot presentation quality (#1972)
* feat(android): bound snapshot presentation quality

* fix(android): bound presentation footprint work

* fix(android): account for presentation scan work

* fix(android): budget scoped snapshot presentation

* fix(android): admit bounded snapshot presentation

* fix(android): validate presentation per window
2026-08-23 17:29:37 +02:00
Michał Pierzchała 71be72c2ed feat(android): carry effective snapshot geometry (#1968)
* feat(android): carry effective snapshot geometry

* fix(android): inherit owning window geometry

* refactor(android): extract snapshot hierarchy builder

* test(android): harden snapshot presentation seams

* fix(android): use narrow chrome contract owner
2026-08-23 17:29:37 +02:00
Michał Pierzchała 1d1367311a perf(find): reuse snapshot index once per ranking pass (#1971)
* refactor(find): isolate match ranking policy

Move find's candidate ordering — on-screen preference, actionability
scoring, and the area/input-order tie-break — out of find-match-resolution.ts
into a focused sibling. Pure move: no score input, ranked order, ambiguity
refusal, or --first/--last behavior changes.

The extraction establishes the seam the follow-up indexing change needs,
so the ranking pass has one production entry point to build a topology in.

Refs #1690

* perf(find): index snapshot topology once per match ranking pass

Ranking a mutating find's candidates asked the whole tree the same three
questions once per candidate: same-rect descendants filtered every node,
the nearest hittable ancestor rebuilt a full index map, and the
overly-broad-ancestor check re-filtered every node for viewport roots.
With m matches over an n-node capture that is O(m x n) full-tree work
before find can act or refuse.

buildActionableTouchTopology reads those three collections in one pass
(nodes by index, children by parent index, normalized viewport-root
rects). preferOnscreenMatches builds exactly one per multi-match pass and
threads it through every score; resolveActionableTouchResolution takes it
as an optional argument so one-off interaction callers keep the cheap
two-argument shape. findNearestAncestor gained the same optional prebuilt
map its snapshot-presentation sibling already accepted, and
classifyActionableTouchCandidates now reuses one topology instead of
building a bare index map and re-resolving per candidate.

Score inputs, ranked order, area and input-order tie-breaks, ambiguity
refusal, and --first/--last are unchanged; only the derivation is shared.

Observed red first: with the wiring hunk removed, the new ranking
regression reports builder calls 0 (expected 1) and 64 filter + 32 map
whole-tree scans (expected 0) over 32 candidates.

The topology docstring records two seams the reviewer asked for. #1690
names src/snapshot/snapshot-processing.ts as findNearestAncestor's home;
that path is gone and packages/contracts/src/snapshot-tree.ts is the seam
that replaced it, so the issue's file list is drifted rather than a second
site to change. And viewportRootRects is not interchangeable with
snapshot-visibility's precomputedViewportRects: normalizeRect drops
negative width/height where hasValidRect keeps them, which changes which
rect wins pickLargestRect.

Refs #1690

* refactor: hide actionable touch indexing
2026-08-23 11:00:22 +02:00
Michał Pierzchała 443bbd0cb8 fix(mutation): move the repo size ratchet where the lane cannot reach it (#1977)
Stryker runs the suite from a sandbox copy under `.tmp/stryker/`, so a test that
asserts about the repository checkout itself — its files on disk, or its git
history — reads a repository that does not exist.

`test-file-size-ratchet.test.ts` is such a gate, and it fails there for two
independent reasons: `disableTypeChecks` (Stryker's default) prepends
`// @ts-nocheck` to every copied file, so all 26 pinned files read one line
longer than they are; and the sandbox has no `origin/main`, so the gate's
history-backed half cannot resolve its merge-base. Fixing either leaves the
other. Its own `.tmp` skip entry cannot help: that is matched relative to
`REPO_ROOT`, which inside the sandbox *is* the sandbox.

Move it to `scripts/__tests__/` and include it explicitly in `unit-core`, the
address the repo already uses for maintained gates that are not `src` tests.
`KERNEL_TEST_FILE_RE` admits only root/package `src` tests, and its comment
already names `scripts/__tests__` as unreachable by construction — so the gate
leaves every mutation lane by virtue of where it lives, with no classifier to
recognise it and nothing to keep in sync.

This replaces the source-text scanner of the previous revision, which was the
wrong boundary: it sniffed for a single-quoted `walk-files` import or the string
`origin/main`, so a behavioral test could match and be silently excluded while an
equivalent repo gate using double quotes, another walker, or another base ref
would be missed. The scanner, its test, and its justifying comment are all gone.

`REPO_ROOT` and the walked roots are unchanged — both addresses are two levels
below the repo root, and `TEST_ROOTS` already included `scripts`, so the gate
measures exactly what it did before.

The one new assertion pins the invariant this now depends on: `isKernelTestFile`
accepts root/package `src` tests and rejects `scripts/__tests__`. Widening that
pattern would silently pull the gate back into every lane.

Verified with `pnpm mutation:run --modules kernel-errors`: scope 804 -> 803 test
files, dry run clean, lane `pass` at stage complete, score 74.8%
(187 killed / 63 survived / 250) — unchanged. `pnpm mutation:test` 39/39,
`pnpm check:layering` 181/181, `typecheck`, `lint`, `format` clean.
`stryker.config.json` is untouched, so scores stay comparable.

Unblocks #1964, whose two mutation checks fail on main's tip without its code.
2026-08-23 07:39:49 +02:00
Michał Pierzchała 04e4c23b95 dx(check): fail fast when node_modules lags the lockfile (#1967)
* dx(doctor): flag a worktree whose node_modules lags the lockfile

Add a doctor probe and a check:affected preflight that compare
node_modules/.pnpm/lock.yaml (the exact lockfile snapshot pnpm installed
from) against pnpm-lock.yaml via a content hash — no subprocess. On
mismatch both surfaces report the same one-liner: "node_modules was
installed from a different lockfile; run pnpm install", so a stale
install names its own cause instead of surfacing as bogus format diffs
on files a change never touched (the #1956 incident).

Closes #1963

* fix(doctor): scope the node-modules probe to a local source checkout

The probe ran unconditionally from findProjectRoot(), so it fired in two
contexts it cannot diagnose:

- Packaged installs. Published packages ship neither pnpm-lock.yaml (not
  in the package.json `files` allowlist) nor an installed snapshot, so
  every end user's `doctor` gained a spurious node-modules line and a
  degraded overall status.
- `--remote`, where the daemon's own root describes the server
  deployment rather than the caller's worktree, so the answer could not
  address #1963 at all.

Whether a root is a source checkout is now decided by the presence of
pnpm-lock.yaml itself rather than a heuristic about install location, and
'no-source-checkout' is a distinct result rather than a warning, so the
packaged case cannot be represented as a defect. The probe returns
undefined there and the route appends no check, matching how doctor
already models an out-of-scope question (the device family is likewise
absent under --remote). The fresh-worktree catch is preserved: a lockfile
with no installed snapshot is still a hard failure.

The check:affected preflight is unchanged in behavior.

Route-level assertions cover all three contexts (source, packaged,
remote); each was verified to fail against the pre-fix wiring.

* refactor(check): keep stale-install probe worktree-local
2026-08-22 17:01:04 +02:00
Michał Pierzchała 03c3984066 perf(contracts): granularize entry surfaces so hub importers stop evaluating the facade clump (#1969)
* perf(contracts): granularize entry surfaces so hub importers stop evaluating the facade clump

`@agent-device/contracts/platform` unions 32 vocabulary modules and
`/interaction` another 18. A file that value-imports either evaluates the whole
union to reach one function, and because permanent hubs sat behind them —
`command-descriptor/registry.ts`, `core/capabilities.ts`,
`interactors/register-builtins.ts`, `command-descriptor/platform-execution-entry.ts` —
that union rode into roughly half the unit suite's test graphs.

Give every vocabulary module its own entry subpath and move all value-importers
onto the module that owns the symbol. Type-only importers are left alone: `import
type` is erased, so it already evaluated nothing.

Measured with the #1950 eager-import-closure walker over all 974 unit-core test
files, against base e5bfde3d1:

  aggregate eager module evaluations  143,248 -> 129,738  (-9.4%)
  facades/platform.ts   carried by    466 -> 1 test graphs
  facades/interaction.ts carried by   451 -> 0 test graphs

  registry.ts                 105 -> 66
  capabilities.ts             113 -> 76
  register-builtins.ts        111 -> 73
  platform-execution-entry.ts  43 -> 3
  dispatch.ts                 134 -> 100

Three gate adjustments the split forces:

- R11's pinned contracts subpath list grows to the new entries, and the resolver
  test's "must not resolve" example moves to `./clipboard`, since `./gesture-plan`
  is now a real entry.
- R16 anchored the record-runtime join on the literal `contracts/platform`
  specifier. It now accepts any contracts entry — the assertion's provenance is
  what the rule pins, not which subpath carried it.
- `gesture-plan.ts` became an entry target, and the no-bare-star rule rejects the
  `export * from './gesture-plan-types.ts'` it carried. Its one internal consumer
  now imports the owning module directly.

Both facades keep their type re-exports for the ~490 type-only importers, so
every symbol on them now reads as value-unused; one fallow entry records that
and names retiring them as the follow-up.

Closes #1959

* test(contracts): text-filter the facade scan before parsing

The repo-wide scan parsed all ~3000 sources, which the coverage lane's
instrumentation pushed past both the 5s test timeout and the 2.5s slow-test
budget. A file that never names the specifier cannot import it, so filter on the
text first and parse only the ~490 candidates.

Non-vacuity moves with it: instead of counting narrow imports across every file,
require that the surviving type-only importers were seen and classified as
erased — which an empty scan cannot satisfy.
2026-08-22 15:35:38 +02:00
Michał Pierzchała 7f3e355426 fix(ios): preserve regular snapshot depth through structural wrappers (#1947)
* fix(ios): complete regular snapshot depth frontier

* fix(ios): align depth frontier with visibility fold

* fix(ios): exercise regular depth frontier in CI

* fix(ios): cover visible-depth frontier through public snapshot

* fix(ios): tolerate absent deep-link confirmation

* test(ios): expose visible-depth fixture hierarchy

* test(ios): wait for visible-depth fixture subtree

* fix(ios): keep visible-depth fixture minimal

* fix(ios): update snapshot hint fixtures

* test(ios): avoid fixture label aggregation

* test(ios): match fixture raw hierarchy

* test(ios): prove visible-depth raw ancestry

* test(ios): align depth smoke with AX hierarchy
2026-08-22 13:53:39 +02:00
Michał Pierzchała e5bfde3d13 diagnose(1874): instrument the synthesized commit wait and add a dispatchable stall loop (#1941)
* diagnose(1874): instrument synthesized commit wait and add stall loop workflow

* diagnose(1874): fix empty-array expansion under set -u; raise default iterations

* diagnose(1874): add arm64 matrix leg to isolate the Rosetta factor

* ci: build the iOS runner for the native arm64 slice

A generic simulator destination leaves the active arch undefined; Xcode 26.6
defaults it to x86_64, running the whole runner under Rosetta on arm64 hosts.
Pin ARCHS=arm64 across every lane that builds the iOS runner and bump the
derived-data cache suffixes. Measured ~30% faster commits on identical CI
hardware; delivery-throttle episodes still occur but start from a lower base.

* diagnose(1874): keep commit-wait cadence evidence value-free

The per-poll trace logged the observed field's contents (prefix(40)) on the
shipped type path; that value is user content and runner.log persists. Log
lengths and the expected-prefix walk instead, allowlist every
string-interpolating NSLog format in the module behind a source-scan guard,
and pin commonPrefixLength in the host-lane policy tests.

* diagnose(1874): narrow the log-format match for typecheck

* diagnose(1874): route cadence evidence through a typed value-free boundary

logCommitCadence accepts Int lengths and a timestamp only, so observed field
contents are unrepresentable at the poll call site; its emitted line is pinned
by a sentinel-secret test in the host-lane policy tests. The source guard
becomes structural — boundary present, poll path logs through it, no raw NSLog
in the observe closure — instead of parsing Swift format strings. #1874 is
reopened as the removal-tracking thread for this temporary instrumentation.
2026-08-22 13:39:09 +02:00
Michał Pierzchała cb65d6ca1f refactor(tests): replace the test-utils barrel with direct module imports (#1956)
* refactor(tests): replace the test-utils barrel with direct module imports

The barrel re-exported 13 modules, so every importer evaluated all of them
(store-factory alone drags 16 daemon session-store files; property-arbitraries
drags fast-check). Importing the backing modules directly cuts the unit
suite's aggregate eager module evaluations from 153,401 to 144,344 (-5.9%),
measured with the eager-import-closure walker. Deleting the barrel makes the
tax unrepresentable instead of pinning it with a guard test.

* docs(testing): point fixture guidance at the test-utils modules, not the deleted barrel

* test: extract replay session fixture
2026-08-22 12:21:35 +02:00
Michał Pierzchała 991c08561b fix(ios): enforce regular snapshot clip invariant (#1946)
* fix(ios): enforce regular snapshot clip invariant

* fix(ios): restore typed snapshot failure construction

* fix(ios): linearize snapshot clip validation

* fix(ios): propagate snapshot presentation errors

* fix(snapshot): clarify presentation failure recovery
2026-08-22 12:15:40 +02:00
Michał Pierzchała aa81666110 refactor(daemon): lazy-load platform cleanup helpers in session teardown (#1950)
* refactor(daemon): lazy-load platform cleanup helpers in session teardown

Every teardown caller paid the static graphs of android perf, the android
snapshot helper, and apple xctrace cleanup (146 repo modules total) even when
the corresponding capture never ran on the session. The three helpers now
load through await import behind their existing guards, matching the
register-builtins interactor lazy pattern. Teardown's runtime graph drops to
84 modules.

The apple hunk extends the verified android pair to the identical guard
shape (perf-xctrace is 42 modules on its own); drop it if it should stay
android-only.

* test(daemon): pin the teardown lazy seam with an eager-closure guard

Extracts the AST-based eager-import-closure walker from
cli-startup-import-closure.test.ts into eager-import-closure.fixtures.ts and
adds session-teardown-import-closure.test.ts, which fails if either android
cleanup helper returns to a static import. Observed red against main's
static imports before green.

Reverts the apple perf-xctrace lazy hunk: the Apple unit test mocks the
cleanup module, so it could not prove the dynamic chunk loads on the shipped
path.

* fix: explicit .ts import extensions for NodeNext resolution and formatting

* refactor(daemon): inline the android lazy imports into their sole teardown callers

Review P2: the wrapper functions only forwarded arguments and reconstructed
the native-perf type. Each await import now sits directly next to its
existing guard.

* test: tighten teardown import-closure fixture
2026-08-22 12:14:35 +02:00
Michał Pierzchała 919c478013 perf(typecheck): make the root tsc project incremental (#1957)
tsc -b packages/* already caches via composite builds; the root
tsc -p tsconfig.json re-checked everything from scratch on every run.
With incremental + a gitignored .tmp build-info file, a repeat
typecheck drops from 2.0s to 0.4s wall (measured on a warm machine);
cold runs are unchanged. *.tsbuildinfo and .tmp/ are already ignored.
2026-08-22 11:05:30 +02:00
Michał Pierzchała ee3a2d1105 docs: drop stale apps.ts over-budget warning from AGENTS.md (#1954)
apps.ts was extracted into app-resolution/app-launch/app-device-io/app-settings
modules; it is now a 15-line barrel, so the extract-before-adding warning no
longer applies. session.ts remains over budget and keeps its entry.
2026-08-22 11:05:02 +02:00
Michał Pierzchała bc56e750ee refactor: dedupe containsPoint and rectArea into @agent-device/kernel (#1953)
Three byte-identical copies of point-in-rect (android fill-verification,
interaction-touch-point) and rect area (screenshot-diff overlay matches)
collapse into the existing kernel/rect helpers. The linux snapshot rectArea
undefined-tolerant variant is intentionally left alone: its ?? 0 semantics
differ from the kernel's non-null contract.
2026-08-22 11:04:41 +02:00
Michał Pierzchała 320a881ae3 perf(ios): memoize simulator app url-scheme probes per bundle mtime (#1939)
resolveIosSimulatorDeepLinkBundleId spawned one plutil process per installed
app on every deep-link open (1.5-9s on a populated simulator). Cache schemes
keyed on Info.plist path+mtime so reinstalls invalidate naturally; an unreadable
plist path falls through to the uncached probe, preserving prior error behavior.
2026-08-22 07:57:31 +02:00
Michał Pierzchała 3e584d1d5d fix(ios): isolate snapshot acquisition timing from presentation (#1948)
* fix(ios): isolate snapshot acquisition timing

* fix(ios): pin snapshot phase timing fixture

* fix(ios): avoid starving synthesized text commits
2026-08-21 22:10:47 +02:00
Michał Pierzchała d2f2dae798 perf(ios): build structural-identifier suppression child index once per pass (#1938)
collectIosStructuralIdentifierSuppression rebuilt a full node map and walked
every candidate's ancestor chain per identifier-only wrapper (O(candidates x n x d)
on every iOS snapshot). Collect descendants via one childrenByParent index per
pass, preserving exact parent-link semantics for trees without depth fields.
collectDescendantsByParentIndex had no remaining callers and is removed.
2026-08-21 20:25:23 +02:00
Michał Pierzchała 3bf3ff130a fix: Linux input/a11y defects from #1935 (click miss, typed '=', GTK4 text) (#1949)
* diag: instrument Linux CI to gather evidence for #1935 input/a11y defects

Temporary — adds a diagnostic step that dumps raw AT-SPI interfaces/actions
for gnome-calculator's digit buttons, tests a raw xdotool click at a
button's own rect (bypassing our promotion logic), and isolates the typed
'=' character in several configurations. Will be removed once the real
fixes land.

* diag: harden diagnostic step against bash -e and AT-SPI registration races

The prior version crashed 7s in: GH Actions runs steps under bash -e, and
an unguarded python3 heredoc threw (iterating a dict instead of a list
when the app wasn't found yet), aborting the rest of the script silently
under continue-on-error. Guards every fallible command, and replaces the
fixed 2s sleep with inspect.py's own poll-until-found loop.

* diag: test WINDOW coordtype and static Text.get_text call (round 3)

Round 2 proved Component.get_extents(SCREEN) returns (0,0) for every
non-toplevel widget (real click miss confirmed on-screen), and
Text.get_text() throws — a documented PyGObject binding collision with
the deprecated 1-arg Accessible.get_text(). This narrows to the two
candidate fixes before writing them: does CoordType.WINDOW give usable
relative offsets, and does Atspi.Text.get_text(accessible, ...) (static
call) return the real typed text.

* fix(linux): resolve click-miss, dropped '=', and GTK4 text exposure defects

Three defects surfaced by CI on #1935 (Linux Smoke lane), all confirmed
live via instrumented CI runs before being fixed here:

1. Click misses its target: Component.get_extents(Atspi.CoordType.SCREEN)
   returns (0, 0) as the origin for every non-toplevel widget under this
   GTK4 build — confirmed by a raw click at the computed rect center
   landing on the window's own header-bar button instead of the intended
   digit button. CoordType.WINDOW gives correct, distinct per-widget
   offsets, so get_rect() now computes screen-absolute rects as that
   offset plus the enclosing top-level frame's own (correct) screen
   origin, threaded through traverse_node() alongside the existing
   window-title tracking. Complementary hardening: role "label" is now
   excluded from `hittable`, since GTK4 wraps every button's caption in a
   same-rect "label" child, and the shared cross-platform promotion logic
   in interaction-targeting.ts would otherwise retarget a click from the
   button onto that non-interactive label.

2. Typed '=' never arrives: a single isolated synthetic keystroke sent
   right after a focus change is unreliably delivered — confirmed live,
   both `xdotool type -- "="` and `xdotool key equal` sent alone produced
   no character at all, while multi-character bursts always landed in
   full. typeLinux and sendKey now wait a short settle margin before
   dispatching to xdotool/ydotool, absorbing the race regardless of which
   action last changed focus.

3. GTK4 apps expose no editable text: accessible.get_text_iface().get_text()
   throws "Atspi.Accessible.get_text() takes exactly 1 argument (3 given)"
   — a documented PyGObject binding collision between Text.get_text and
   the deprecated 1-argument Accessible.get_text, silently swallowed as
   "no text" by the broad exception handler. get_text_value() now calls
   the unbound Atspi.Text.get_text(accessible, ...) form, which correctly
   returns the real content.

The Linux smoke replay is restored to exercise all three fixes together
(click a resolved digit button, type a full calculation including the
'=' keystroke, wait on the computed result through the tree) instead of
staying at the weakened, contract-tier assertions the defects had forced.
The coverage manifest promotes click and type from command-contract to
live accordingly.

* fix(linux): drop unproven keyboard-settle and hittable changes per review

Addresses thymikee's review on #1949 (both points correct):

P1: the keyboard settle (typeLinux/sendKey) was unjustified. The cited
diagnostic evidence for a dropped '=' actually shows the opposite —
"100+55=" and "5=5" both computed correctly with zero settle, proving
'=' was delivered in every multi-character burst tested. Sending '='
alone to an empty entry showing a blank display is normal calculator
semantics (nothing to evaluate), not a lost keystroke. The likelier
explanation for the original "100+55" screenshot (run 32487868346) is
that its attempt-3 hit the already-fixed mousemove --sync hang, not an
independent keyboard-dispatch defect. Reverted; no keyboard-dispatch
change was needed.

P2: the `role_name != "label"` hittable narrowing was extra surface
beyond what the click-miss fix required. The corrected AT-SPI coordinates
alone fix the observed miss — the button and its same-rect label child
resolve to nearly identical centers, so descendant promotion still lands
inside the button either way, and the replay can't distinguish which
node it actually targeted. Reverted; only the coordinate fix remains.
2026-08-21 20:02:17 +02:00
Michał Pierzchała 1f80c92a27 refactor: derive every --settle surface from the descriptor trait (#1652) (#1945) 2026-08-21 19:50:57 +02:00
Michał Pierzchała d0547dcb97 refactor: complete the find cutover onto the request-bound runtime (#1944)
* refactor: complete the find cutover onto the request-bound runtime

The deferred Wave 4 unit for #1739 (R35), unblocked by focus (R40) and type
(R41). Find's read-only legs, focus leg, and type leg already ran bound; the
one remaining direct platform execution was the mutating-target capture, which
built createSelectorCaptureRuntime without a bound capture and fell through
the legacy dispatch branch reserved for "the last one to migrate".

- The mutating path now enters resolveBoundSelectorCapture — the selector
  family's shared admit-then-bind entry, which already named find in its
  intent table — and threads the bound capture into the target capture.
- Find was that last one: `capture` on SelectorCaptureRuntimeParams is now
  required and the legacy fallback branch is deleted. The backend's `bound`
  becomes required-to-state, with the observation-free duration wait
  (`wait 400`) as the one declared absence — its runtime now carries no
  capture backend at all, so an accidental capture fails loudly instead of
  falling anywhere.
- The descriptor flips to device-runtime with findRuntimePlanUses (the
  selector-text plans shared with get, plus focusRuntimeUse and
  typeTextRuntimeUse); the capability bucket and both overlay memberships
  (HARMONYOS_SUPPORTED_COMMANDS, WEB_QUERY_COMMANDS) are deleted.
- R35 lands with the selector family's shared operation owners; the capture
  and backend tests move off the dispatch mock onto the bound seam, which is
  where the poll-deadline and private-ax-pin assertions actually live now.

Wave 4 is complete: layering recognizes 26 migrated commands.

* fix(find): one action-selected bind per mutating handler (ADR 0019 §9)

Review P1 on #1944: a mutating find performed up to three separate
facts/admit/bind projections — capture, then focus, then type re-admitted
per leg. The request handler now resolves ONE action-selected plan and binds
once:

- New selector intents find-focus / find-type carry combined uses
  (capture + focusPoint, capture + focusPoint + typeText) through the same
  admit-then-bind path and plan machinery every selector capture uses; the
  new bind arms reuse the existing capture selectors, so the shared operation
  owners stay single. Delegated click/fill resolve targets on the plain
  capture pair.
- The handler threads the one bind's operations to the shared executors:
  executeFocusPoint is extracted as the single lexical owner of the focusPoint
  call (R40's owner claim follows it), and executeBoundTypeText's runtime
  param narrows to the operations it actually uses so find can pass its own
  broader bind through it.
- findRuntimePlanUses becomes the full action-selected set (eight uses), and
  the descriptor test pins each use's exact requirement list.
- Regression: find focus and find type each assert exactly one facts
  inspection and one bindDevice call — the pre-fix handler fails both (two
  and three binds respectively).

Live re-verified on iPhone 17 Pro at this head: find focus and find type both
execute through the single bind, route synthesized-first-responder, typed text
visible in the captured tree.
2026-08-21 18:43:09 +02:00
Michał Pierzchała 34c14a55dc refactor(recording): share the AVFoundation export pipeline between overlay and trim (#1943)
* wip: shared recording export support

* refactor(recording): share the AVFoundation export pipeline between overlay and trim

Extracts RecordingExportSupport.swift (error vocabulary, flag-value reading,
composition assembly, bounded export wait) so recording-overlay and
recording-trim stop carrying three near-identical copies of the same
mechanics. Entry points move to @main because multi-file swiftc reserves
top-level statements for main.swift.

compileSwiftSourceFile gains extraSourcePaths: extra units join the cache key
and reach swiftc, and overlay.ts passes the shared support file for both
scripts. Error messages and per-script stderr prefixes are unchanged; trim's
error-precedence order (missing video track before invalid range) is
preserved by resolving the track before the range guards.
2026-08-21 18:42:35 +02:00
Michał Pierzchała 1e68bf2917 fix(web): read enabled=false from the snapshot [disabled] annotation (#1940)
The agent-browser refs payload carries only role+name, so web snapshot nodes
never reported enabled=false; the [disabled] bracket annotation in the snapshot
text (emitted for both the attribute and aria-disabled) went unparsed. Quoted
label text is stripped before scanning so a literal "[disabled]" inside a label
does not flip the flag; explicit refs metadata still wins. Focus state is not
emitted by the pinned backend and remains metadata-only.
2026-08-21 17:33:23 +02:00
Michał Pierzchała 93f8ae0096 chore: ignore host-local workspace artifacts (#1942) 2026-08-21 17:27:26 +02:00
Michał Pierzchała 81409f1a7c refactor: migrate type to the request-bound device runtime (#1935)
* refactor: migrate type to the request-bound device runtime

Wave 5 unit 2 for #1739 (ADR 0019), find's last blocker. `type "text"` and
`find <q> type "text"` now reach the device through one admitted, request-bound
`typeText` operation instead of the `handleTypeCommand` interactor leaf and its
dispatch-table arm.

- New `TypeTextRuntimeOperations` contract riding the same `Interactor` seam as
  focus/screenshot/element-text; the operation returns the interactor's own
  closed `TypeTextBackendResult`, so Apple route evidence passes through and
  every other owner types blind, exactly as before. The iOS synthesized-type
  commit wait (#1676) is Apple-interactor-internal and moves nowhere.
- The interaction backend's `typeText` member exists only when the `type`
  handler admitted and bound a runtime — no caller can fall back to legacy
  dispatch, so the command keeps exactly one execution path (R41).
- `executeBoundTypeText` reproduces the retired leaf byte-for-byte: leading-ref
  rejection with the same hint, space-joined positionals, the 0-10000 delay
  bound, and only textEntryRoute surviving from the owner's result. Its parse
  pins moved from the dispatch-level tests into the daemon runtime test.
- Exact-owner facts replace the capability bucket (apple sim+device, android
  all-but-simulator-row, harmonyos emulator+device, linux device, web device,
  vega unavailable, providers wherever their interactor is reachable) and
  `type` leaves HARMONYOS_SUPPORTED_COMMANDS / WEB_INTERACTION_COMMANDS.
- The Linux desktop replay types a digit on real hardware; the coverage
  manifest promotes `type` contract -> live with the two-sided count pins.
- Android/webdriver facts helpers extracted (androidTouchFact, interactorCell)
  to keep inspectFacts under the complexity gate.

`find` stays legacy: both of its direct execution legs now share bound
runtimes, so the atomic R35 cutover is next.

* refactor(type): single-pass daemon routing, shared binder source, owner cell tests

Review follow-ups on #1935.

- The type handler now calls the bound executor directly: the interaction-
  runtime hop validated and formatted what executeBoundTypeText validates and
  formats again, so it is gone — no boundTypeText backend member, no second
  result rebuild. The ADR 0014 frame expiry moves to the handler.
- New contracts/interactor-operation-binding.ts: one local resolver and one
  fail-closed provider resolver shared by the screenshot, focus, and type
  binders — three private copies retired, provider error text preserved.
- provider-limrun/interaction-operations.ts: the interactor-backed interaction
  cells move out of the app-log owner (586 -> 563 lines, below its pre-unit
  size); text interaction is composed by that owner, not defined in it.
- Every owner runtime test now pins the focusPoint/typeText fact cells and
  bound-operation presence for its exact kinds: apple, android (incl. the
  synthetic-simulator refusal), harmonyos, linux, web, vega (refusal + hint),
  webdriver (reachability-gated, incl. inactive session), limrun (live +
  recovery). The webdriver unsupported-capability row documents that
  interaction gates on interactor reachability, not capture declarations.
- The Linux replay assertion is now change-sensitive: type "555" then wait for
  a 555 node — no calculator button carries that label, so the wait passes only
  if the keystrokes landed in the display; deleting the type step turns it red.

* fix(replay): give the calculator focus before the Linux type assertion

The change-sensitive wait exposed what the review predicted: the typed digits
never landed, because `focus 100 100` clicks the DESKTOP and takes keyboard
focus away from the calculator. The old broad assertion masked exactly this.

The retries then wedged on a latent quirk: attempt-1 leaves the pointer at
(100,100), and the next attempt's `xdotool mousemove --sync` to the same point
waits for a motion event that never comes, so every retry dies at the focus
step with a 10s timeout — which is why the lane reported step 7, not the
failing wait.

New tail: `focus 100 100` (R40 evidence + survival assert), then
`click "label=1"` — a resolved press inside the window that restores keyboard
focus, proves pointer input lands in the app, and moves the pointer off
(100,100) so retries cannot trip the mousemove no-op hang — then `type "55"`
and `wait "label=155 || text=155 || value=155"`. No button is labelled 155, so
the wait passes only if the typed keystrokes reached the display.

* refactor(type): delete the fallow SDK typeText surface, drop dead surface fields

Thermo-nuclear review follow-ups (reviewed at 82fb8c2dc; the three-hop relay
it names was already deleted in 3d44b6dea — these are the residuals).

- typeTextCommand had zero production callers after the direct-executor
  routing: the daemon was its only consumer, and the released SDK types over
  the wire (executeCommand('type')), not through the embedded runtime catalog.
  Deleted: the command, its Options/Result types, the catalog registrations,
  the AgentDeviceBackend.typeText member, and every fixture/pin that kept the
  dead surface alive. R41's retirement claim now names typeTextCommand, so a
  revival fails the gate. One parse/compose owner remains: executeBoundTypeText.
- TypeTextInput.options.surface and FocusPointInput.options.surface were dead
  clones — never set by a projector, never read by a binder. Removed both.
- The Linux replay click disambiguates with role=button: the calculator tree
  carries [text] digit nodes beside the buttons, so a bare label=1 was an
  AMBIGUOUS_MATCH rejection on attempt-1 — which parked the pointer at
  (100,100) and made every retry hang in the xdotool no-op move, reporting as
  the step-7 focus timeout.

Live-verified on iPhone 17 Pro at this head: coordinate focus -> bare type ->
route synthesized-first-responder -> text observable in the captured tree.

* fix(ci): lower the settle.test.ts pin, add pixel evidence to the Linux type wait

- settle.test.ts shrank to 2359 lines when its dead typeText fixture member
  left; the ratchet pin follows it down (the history-backed gate caught the
  gap on CI while the local affected run had not re-selected the ratchet).
- The Linux replay now screenshots the calculator right after the type step.
  The lane uploads test/screenshots/replays/*.png on pass and fail, so a
  wait-155 timeout becomes diagnosable from the artifact: display showing 155
  means a tree-exposure gap; an empty display means the input never landed.
  The 20-ref divergence dump cannot show the entry node either way.

* fix(replay): assert the Linux type through the computed result

Run 32485981780's typed-state artifact settled both open questions with one
image: the display shows "55" — the bound typeText keystrokes LAND on Linux
CI — while the wait for that value timed out, so the calculator entry does not
expose its text to selectors; and the display shows no leading "1", so the
resolved click on button 1 missed its target entirely.

Both discoveries leave the replay: the click dependency goes (a pre-existing
click-coordinate defect is not this unit's evidence chain), and the assertion
moves to where the tree can answer — the typed string is now a full
calculation ("100+55=") whose `=` creates a history row, and the wait matches
the computed 155. No button carries that label and the typed string never
contains it, so the wait passes only if the keystrokes executed; deleting the
type step turns it red. The typed-state screenshot stays as per-run pixel
evidence either way.

* fix(linux): guard the no-op --sync mousemove; assert typed digits via value=

Two artifact PNGs decided this. Run 32485981780 shows "55" in the entry while
the wait for it timed out on a mismatched target; run 32487868346 shows
"100+55" — digits and + land, the = keystroke does not, and the retries died
in the focus step again because the previous commit removed the pointer-moving
click.

- moveTo now probes `xdotool getmouselocation --shell` and skips the --sync
  move when the pointer already sits on the target: a no-op move emits no
  motion event and hangs until the action timeout, which is what reported
  every failed replay retry as its first coordinate step. A failed probe never
  blocks the move. The provider test pins both sequences, including the
  skip case.
- The replay types digits only ("155") and waits on `value=155`: the AT-SPI
  dumper reads the entry's Text interface into the node's value and the
  selector engine matches it — the earlier "exposure gap" conclusion came
  from a pair that never tested the matching value. No button carries 155, so
  the wait passes only if the keystrokes executed.

* fix(replay): keep Linux type at contract tier — GTK4 exposes no entry text

Run 32490373693 closed the investigation: the typed-state artifact shows
"155" in the calculator entry while the wait for value=155 timed out with no
interactive filtering in play and a matcher that does compare node.value. The
AT-SPI dumper's get_text_iface() route returns nothing for GTK4
gnome-calculator, so no tree-level assertion on typed text can hold on this
lane today.

The replay keeps the type step and uploads the typed-entry screenshot every
run — live pixel evidence that the migrated typeText path lands keystrokes on
real Linux hardware — and closes with the survival assertion. The manifest
claim returns to the contract tier with the reason written at the entry, and
the count pins follow. The GTK4 exposure defect joins the Linux input-defect
chip; fixing PyGObject-vs-GTK4 blind through CI rounds is not a sane loop.

The moveTo no-op guard from the previous commit stays: it is why this run
finally reported the true failing step on every attempt instead of the
step-7 hang.
2026-08-21 17:15:35 +02:00
Michał Pierzchała 580bb5946a fix(ios): make snapshot presentation construction private (#1937) 2026-08-21 16:51:07 +02:00
Michał Pierzchała 5676d5ff8a refactor(apple): drop dead runner code and collapse duplicated helpers (#1936)
* refactor(apple): drop dead runner code and collapse duplicated helpers

Removes declarations with no consumers (findScopeElement, interactiveTypes,
two unused PresentedNode convenience inits) and collapses copy-pasted logic:
DataPayload now relies on the synthesized memberwise init, TvRemoteButton is
String-raw-valued, point-hit sorting shares smallestElementFirst, command-id
trim-or-nil lives once on RunnerCommandJournal, scroll/desktopScroll share
direction and durationMs validators, and the seven inline NSError refusals use
unsupportedOperationError. elementTypeName reads a table pinned by the
visibility-fold parity test.

The packager now skips files whose unit-test blocks were their whole body, so
10 test-only files stop shipping (and stop compiling on user machines) as
empty translation units.

Packaged Swift: 432.3 kB -> 427.8 kB; 56 files instead of 66. Net -144 lines.

* test(apple): pin skeleton-file exclusion in the packaging guard

The strip fixture always kept runtime content, so reverting the
skeleton-skip branch left every guard green. The new fixture's whole body is
unit-test blocks; the packaged path must be absent while a non-skeleton
sibling still ships. Observed red with the skip branch disabled before
re-enabling it.

* refactor(apple): tighten runner cleanup boundaries
2026-08-21 16:42:17 +02:00
Michał Pierzchała 766d42e124 fix(ios): report an unobserved text commit instead of a partial success (#1924)
* fix(ios): report an unobserved text commit instead of a partial success

awaitSynthesizedFirstResponderCommit returned Void, so its three exits were
indistinguishable to the caller: the expected text committed, the app
transformed the input, or the 3s deadline expired with a strict prefix still
outstanding. The caller returned dispatched-with-no-failure in all three, and
`type` answered ok with textEntryRoute synthesized-first-responder over a field
holding part of the requested text.

The wait now returns a SynthesizedTextCommitOutcome and an expired deadline
becomes TEXT_INPUT_COMMIT_NOT_OBSERVED, whose hint points at fill rather than a
type retry — type appends, so retrying it concatenates onto whatever committed.

The tail is still not re-synthesized: #1676 rejected that because a stalled
prefix cannot be told apart from a suffix still queued, so repair double-posts.
Reporting is what the runner does instead.

typeIntoCurrentTarget loses its `dispatched` flag, which was exactly
`failure == nil` and could not express the new state — characters posted, commit
unconfirmed, command must refuse. Failure is now the single discriminator.

The decision moves behind an injected clock/observer so the deadline branch runs
in the macOS host lane on every PR instead of needing a simulator.

Refs #1874, #1844

* fix(ios): close false-failure windows in the commit wait

Adversarial review found two deterministic false failures in the wait added by
the previous commit, plus a message that asserted a field state never read.

The deadline was checked before observing, so a commit landing during the final
poll sleep was condemned as never observed — under exactly the loaded-host
timing the wait exists for. The check now runs after an observation, so the last
thing before condemning is a read.

`treatingPlaceholderAsEmpty` maps a value equal to the field's placeholder to
"", a prefix of every expected value. `type "0.00"` into a field placeheld
"0.00" committed instantly, read as pending for the full 3s, and failed. The
observation now settles on an exact raw match; the normalized read still drives
the prefix walk.

The outcome-to-failure mapping moves to textEntryFailure(forCommitOutcome:) so
the branch the command refuses on is pinned by a test rather than living only in
a ternary. `.unobservable` staying a success is what keeps `type "...\n"`
working, and it now has an assertion.

Message and hint no longer claim the field holds a partial value: under both
fixed windows it may hold all of it. The docs sentence no longer implies every
text-entry route verifies its result — the replacement and keyboard-visible
routes have no resolvable element to observe and are unchanged.

Refs #1874, #1844

* test(ios): pin the placeholder fix at the boundary it actually lives on

Review [P1]: testValueEqualToThePlaceholder… injected an observe closure that
already returned "0.00", so it never supplied the normalized "" that causes the
failure. The raw-value short-circuit lived in the production observe closure,
which that test bypassed entirely — reverting the fix left it green.

The raw-exact/normalized-prefix choice moves into commitObservation, and the
test drives it with (raw: "0.00", normalized: "", expected: "0.00"). Reverting
commitObservation to always return the normalized reading now fails the
exact-match assertion.

normalizedValue is a closure rather than a value so an exact match still costs
one accessibility read instead of two, on a path that polls every 20ms for up to
three seconds; a second test pins that laziness.

The old test is deleted rather than kept: its remaining assertion (an exact
match settles without polling) is already covered by
testSynthesizedCommitStopsAtTheFirstSettledObservation.

Refs #1874

* fix(ios): never treat placeholder equality as commit evidence

Review [P1]: an empty text field renders its placeholder AS its accessibility
value, which is why editableTextValue(treatingPlaceholderAsEmpty:) classifies
that value as empty. The previous revision's raw-exact short-circuit therefore
matched BEFORE anything committed whenever the requested text was the
placeholder: `type "0.00"` into a field placeheld "0.00" settled on the first
read and returned ok with zero characters delivered — reintroducing the
success-misdescribes-the-device failure this PR exists to remove.

The state is structurally indeterminate. element.value is identical whether the
placeholder is rendering or the committed text happens to equal it, and
placeholderValue does not disambiguate, so no read resolves it and waiting the
deadline out discovers nothing. placeholderMakesCommitUnobservable detects it up
front and reports the commit unobserved, which the caller refuses on.

commitObservation is deleted rather than narrowed: the raw match was only ever
consulted in this exact case, and in this exact case it is not evidence.

The failure message drops its deadline reference — this refusal never waits.

Refs #1874

* fix(ios): scope the placeholder refusal to an empty baseline

Review [P1]: the guard took only the placeholder and the expected text, so it
refused any append whose result happened to equal the placeholder. Value "0" +
`type ".00"` against placeholder "0.00" was refused before a single read, even
though the non-empty pre-dispatch value proves the placeholder is not what is
rendering and a later "0.00" is genuine commit evidence.

The baseline is what decides it, so it is now an input. placeholderCommitEvidence
returns three states rather than a boolean:

  normalRead         expected differs from the placeholder; the placeholder never
                     enters into the observation
  indistinguishable  expected IS the placeholder and the field was empty, so the
                     placeholder was what rendered and no read can resolve it
  rawValueIsEvidence expected IS the placeholder but the field held content, so a
                     raw match is real

Only .indistinguishable refuses, and it still refuses before the wait, since no
read resolves it. .rawValueIsEvidence reaches the observation and settles on the
raw match, which the normalized read would otherwise hide.

commitObservation returns for that third state, now scoped by evidence rather
than applied unconditionally as in the revision that made raw equality a
false success. Both readings stay closures, so normalRead — the ordinary case —
never pays for the raw read.

Refs #1874

* fix(ios): keep placeholder-equal commits conservative
2026-08-21 15:12:43 +02:00
Michał Pierzchała 17da776350 feat(ios): add snapshot backend conformance (#1930)
* feat(ios): add snapshot backend conformance

* fix(ios): load built SDK at live runtime

* test(client): isolate snapshot forwarding regression

* refactor(snapshot): keep backend capability metadata internal

* fix(test): merge backend conformance imports

* fix(snapshot): keep backend forcing internal

* refactor(snapshot): isolate backend capability fixtures

* refactor(snapshot): keep capability governance internal

* fix(ios): align snapshot actionability contract
2026-08-21 15:01:10 +02:00
Michał Pierzchała 30de1597d3 ci: attribute native package size and trim Apple runner (#1934)
* ci: attribute npm package size by shipped component

* refactor: modularize size reporting and trim Apple runner

* ci: preserve size reporter modules across base checkout
2026-08-21 13:46:53 +02:00
Michał Pierzchała 07023eb202 fix(ios): separate snapshot actionability from occlusion (#1933) 2026-08-21 12:47:25 +02:00
Michał Pierzchała d57aa69777 test: add macOS platform command coverage manifest (#1922)
* test: add macOS platform command coverage manifest

* fix: remove unused macOS coverage type exports

* test: route macOS coverage away from iOS lane

* fix: account for host-dependent macOS audio capability

* fix: run macOS coverage manifest in CI
2026-08-21 12:39:35 +02:00
Michał Pierzchała b92ce95e0a chore: refresh root development dependencies (#1923)
* chore: refresh root development dependencies

* fix: keep upgraded tooling compatible with CI

* fix: keep Expo config lint coverage

* fix: restore Expo fixture lint coverage
2026-08-21 12:35:57 +02:00
Michał Pierzchała 46eff36f85 refactor: migrate focus to the request-bound device runtime (#1925)
* refactor: migrate focus to the request-bound device runtime

Wave 5's first unit (#1739, ADR 0019). `focus x y` and `find <q> focus` now
reach the device through one admitted, request-bound `focusPoint` operation
instead of the `handleFocusCommand` interactor leaf and its dispatch-table arm.

- New `FocusRuntimeOperations` contract with local and provider interactor
  binders, mirroring the screenshot/element-text seam rather than inventing a
  second way for one operation class to reach its mechanics.
- Exact-owner facts replace the capability bucket: apple simulator/device,
  android emulator/device/unknown, harmonyos emulator/device, linux device,
  web device, vega none, providers wherever their interactor is reachable.
  That is the retired bucket's cell table, restated as facts.
- `focus` leaves BASE_COMMAND_CAPABILITY_MATRIX and both hand-maintained
  overlays (HARMONYOS_SUPPORTED_COMMANDS, WEB_INTERACTION_COMMANDS).
- R40 is the new parametrized cutover row; `focusPoint` has exactly one owner.
- The `x y` positional parse moves to utils and is shared with the still-legacy
  touch siblings, so a migrated command cannot drift from them.

`find` stays legacy: this unit owns its focus leg only, its `type` leg still
dispatches, and R35 waits on the Wave 5 `type` unit.

* test(focus): cover the owning interactor binders, lower the find ratchet

Review follow-ups on #1925.

P1: focus-runtime.test.ts bound a fake focusPoint, so deleting the interactor
call inside bindLocalFocusInteractor left focus a successful no-op with every
test green. Adds packages/contracts/src/focus-runtime.test.ts, which executes
both binders and asserts resolver context, positional (x, y) forwarding, the
structured missing-provider failure, and that an already-cancelled request
never resolves an interactor at all.

Two planted mutants confirm it bites: removing
`await interactor.focus(input.point.x, input.point.y)` and transposing its two
arguments each fail exactly the two forwarding tests, while the daemon-level
focus and find suites stay green — which is the gap the reviewer named.

Coverage: find.test.ts shrank to 1204 lines when its focus assertion moved off
the dispatch mock; the ratchet pin follows it down.

* test(focus): add live Linux focus coverage to the desktop replay

The Linux `focus` claim rested on the provider scenario at command-contract
level. The desktop replay runs on real Linux hardware in the Smoke lane, so it
now runs a coordinate focus and re-asserts the session survived it.

Coordinate, not selector: the step exists to prove the migrated `focusPoint`
path executes on real hardware, so it must not be able to fail on match
ambiguity or CI layout drift.

Reclassifies focus contract -> live in the Linux coverage manifest and updates
the two pinned counts. The manifest gate is two-sided — a live claim must name
a command the replay actually invokes — so the claim cannot drift from the file.
2026-08-21 11:34:22 +02:00
Michał Pierzchała af96c6608d feat(ios): publish effective snapshot geometry (#1931) 2026-08-21 11:27:04 +02:00
Michał Pierzchała 73db7be2ff feat(ios): move the regular-projection clip fold into snapshot presentation (#1797) (#1929)
* feat(ios): move the regular-projection clip fold into snapshot presentation

Both iOS snapshot backends carried their own copy of the visibility fold: the
tree walker and the private-AX serializer each computed viewport-and-scroll-clip
intersection, ancestor projection, hidden-content hints, and collapsed depth
during acquisition. Hand-synchronized copies of that interpretation are what
produced the scroll-overflow leak class (#1784), and C1 (fact-availability
neutrality) could not hold while acquisition decided what a screen shows.

Acquisition backends are now fact serializers: every traversed node is emitted
at raw traversal depth with its reported frame, and SnapshotAcquisition carries
the viewport. presentRegular runs the one clip fold for every backend --
viewport ∩ scroll clip, the ancestor cursor (an out-of-clip Cell or scroll
container hides its clamped descendants), the sub-pixel decoration rule,
scroll hints booked onto anchors, reparenting with collapsed depth -- and
narrows the emitted hittable to the clip: nothing outside its clip, and nothing
without geometry, is ever hittable, whatever the backend reported. Platform
differences are a SnapshotFoldPolicy input to the shared algorithm (iOS
cursor-projected; macOS/tvOS plain viewport), never a backend exception.

The private-AX backend collapses to ONE serializer for both projections, and
the flat filter-decision family dies with the acquisition gates it fed.

Three intentional edge deltas, each toward one backend-neutral rule: sub-pixel
content-free decorations now drop on every backend (was private-AX only);
labeled offscreen Application/Window carriers survive on every backend (was
tree only), never hittable; query-sweep regular without -i is viewport-folded.
Declared acquisition residues: the traversal-depth budget cut, the sweep's
frameless-element drop, the private-AX bridge's device-side cap.

Refs #1797 (migration step 3, clip-fold delta).

* refactor(ios): isolate snapshot visibility fold
2026-08-21 11:27:04 +02:00
Michał Pierzchała 8e148e20f9 test: complete boundary fault matrix (#1920)
* test: complete boundary fault matrix

* refactor: reuse loopback test harness

* test: tighten boundary fault evidence
2026-08-21 08:49:01 +02:00
Michał Pierzchała 1f8fdd0b5d fix: preserve Maestro clickable-first ordering (#1917)
* fix: preserve Maestro clickable-first ordering

* test: cover Android Maestro clickable-first path

* fix: keep Maestro fixture Android-only

* fix: reveal Android Maestro targets in smoke scenario

* fix: quote Maestro smoke assertion text

* fix: retain Android Maestro clickability evidence
2026-08-21 08:48:36 +02:00
Michał Pierzchała 6604746d49 refactor(web): drop duplicate viewport facts spread in webRuntimeFacts (#1928)
`webRuntimeFacts` spread `viewportRuntimeOperationFacts` twice in the same
object literal. Both spreads declared the same `setViewport` cell — the first
inlined `device.kind === 'device' ? available : openTargetKindUnavailable`,
the second passed `browserDevice`, which is defined as exactly that
expression — so the second silently won and the first was dead.

Keep the `browserDevice` spread, which sits next to the other cells reading
the same value. Behavior-neutral: the resulting facts object is unchanged.
2026-08-21 08:48:14 +02:00
Michał Pierzchała 6911274952 docs: record visionOS/watchOS support decision (#1918)
* docs: record visionOS and watchOS support boundary

* docs: clarify visionOS deployment boundary
2026-08-21 07:55:30 +02:00
Michał Pierzchała 2a9a4ee80c test: add Linux platform command coverage manifest (#1921)
* test: add Linux platform command coverage manifest

* test: address platform coverage review feedback
2026-08-20 21:48:05 +02:00
Michał Pierzchała 96afa9dbd4 test: add tvOS platform command coverage manifest (#1919)
* test: add tvOS platform command coverage manifest

* fix: align tvOS audio coverage denial

* fix: model tvOS audio as host-dependent contract
2026-08-20 21:47:47 +02:00
Michał Pierzchała 56f2671a66 perf: reduce cold iOS runner startup latency (#1927) 2026-08-20 21:47:29 +02:00
Michał Pierzchała 4137e4275e feat(ios): split raw and regular snapshot projections behind one capture hint (#1926)
The private-AX backend interpreted `--raw` as the regular projection: it folded
the viewport and scroll clips and dropped sub-pixel decorations before returning,
so a raw capture that recovered onto it answered with viewport-pruned nodes
labeled raw (#1797 D4). Nothing related the two copies of that decision.

Presentation now exposes the two projections it always implied. `presentRegular`
folds visibility, eligibility, scope and scroll hints; `presentRaw` is the
acquired tree, normalized, narrowed only by a scope or depth the request asked
for. Acquisition reads one derived `CaptureHint` instead of the request itself,
so what a capture may skip is stated once, beside the reason skipping it keeps
the projection complete: scope and its relative depth never narrow, raw depth
does (raw depth is traversal depth), and the raw projection never carries
`interactiveOnly` — `--raw -i` is the acquired tree.

Two structural rules replace the hand-synchronized ones. The raw plan is derived
from `SnapshotBackendKind.supportsRawProjection`, so the query sweep — an
interactive element query with no hierarchy to return — cannot be planned for a
raw request. And presentation compares the requested projection with the hint
the acquisition was captured under, dropping that tier with a structured
`IOS_SNAPSHOT_PROJECTION_MISMATCH` failure rather than presenting it under the
requested label.

Declared residue: a regular `--depth` request still cuts the traversal at that
depth while regular presentation emits collapsed depth, so a node that would
present within the limit can be dropped. The cut is what keeps `--depth 1`
probes cheap; making it complete is the open visible-depth frontier obligation.

Refs #1797 (migration step 3, raw-projection delta).
2026-08-20 21:44:48 +02:00
Michał Pierzchała aed00aef42 docs: capture gesture verification lessons (#1913) 2026-08-20 19:49:55 +02:00
Michał Pierzchała be51870118 fix: make iOS scroll release controlled (#1906)
* fix: reduce iOS scroll overshoot

* fix: make iOS scroll release controlled

* fix: make controlled iOS scrolls deterministic

* fix: preserve continuous drag sampling
2026-08-20 19:49:39 +02:00
Michał Pierzchała 06d27de4d0 test(gesture): assert pan duration in the iOS gesture-lab replay (#1901)
* test(gesture): assert pan duration in the iOS gesture-lab replay (#1584)

The only replay exercising the `gesture pan` command class that regressed
in #1562 asserted a counter, which stays green even if the requested
duration collapses — nothing in CI could catch the regression coming back.

Record an observed-duration bucket from a single-pointer Gesture.Pan's
begin/end timestamps in GestureLab.tsx (iOS-only, so Android's raw-touch
transform handling in the same shared component is untouched), render it
as plain text, and assert it with a one-line wait in gesture-lab.ad. No
runner protocol changes needed.

* style: fix oxfmt line-wrap in GestureLab.tsx

* ci(ios): run the pan-duration canary automatically on every PR

gesture-lab.ad (and its new duration assertion) only runs under full:fixture-replays,
which is currently dispatch-only in replays-manual.yml — the PR-triggered ios.yml lane
runs the smoke tier, and replays-nightly.yml no longer carries device replays at all
(#1781 A1). So the #1584 guard could not actually catch a regression automatically.

Split the duration check into its own minimal, isolated replay
(gesture-pan-duration.ad) and run it as a smoke-tier step in ios.yml, so it's cheap
and doesn't depend on gesture-lab.ad's multi-touch commands, which stay full-tier only.

* test: require pan recognition in duration canary
2026-08-20 19:08:33 +02:00
Michał Pierzchała 1281cf3479 test: add filesystem boundary fault matrix (#1904)
* fix: clean filesystem publish temporaries after rename faults

* fix: preserve publish errors during filesystem faults

* refactor: centralize atomic filesystem publication

* fix: keep atomic fault helpers internal
2026-08-20 18:29:14 +02:00
Michał Pierzchała 5df5ec469d feat(maestro): add positional selectors (#1911)
* feat(maestro): add positional selectors

* perf(maestro): resolve selectors once per snapshot
2026-08-20 18:21:57 +02:00
Michał Pierzchała 0f05fa38a5 feat(maestro): add recursive tree selectors (#1910)
* feat(maestro): add recursive tree selectors

* perf(maestro): resolve scroll selectors once
2026-08-20 18:21:57 +02:00