Commit Graph

197 Commits

Author SHA1 Message Date
Michał Pierzchała e3880f10c8 fix(android-helper): build with the pinned build-tools version, fail on CI when unset (#2568)
build-android-helper.sh compiled with whichever build-tools directory was newest on the
image while every lane that builds a helper installs exactly build-tools;36.0.0. That
selection feeds d8 and aapt2, so a newer package on the runner changed the helper's
bytecode and resources rather than just its packaging, and nothing said so.

The script now takes the version as an input, its last positional or
AGENT_DEVICE_ANDROID_BUILD_TOOLS, resolves it under $SDK_ROOT/build-tools, and checks the
four tools the build actually runs instead of aapt2 alone. An unpinned build is a hard
failure on CI; locally the newest-installed fallback stays and is announced on stderr.

Each lane that builds a helper declares the version it installs and interpolates that same
value into its sdkmanager line and the build environment, so an install and a build inside
one lane cannot drift. setup-android-replay-host exports it to the packaging gate and names
it in both helper cache keys, which are keyed on APK bytes; size.yml and
release-android-snapshot-helper.yml declare it at job level. The fixture-repack cluster
keeps its own pin: a different command family that ships no helper APK.

Closes #2527
2026-09-14 07:35:00 +02:00
Michał Pierzchała ab0c7a4328 fix(scroll): keep the swipe above the keyboard, refuse when it cannot (#2503)
* fix(ios): clip a scroll's swipe above the keyboard, refuse when it cannot

The runner owns the live keyboard frame, so it does the clip and reports what it left: a scroll
answers with `keyboardAvoided` and `keyboardMinY` beside its plan, and refuses with
`SCROLL_KEYBOARD_OCCLUDES_SURFACE` when the keys leave too little band to swipe in instead of flinging
into them. It never dismisses the keyboard, which would drop focus and mutate state that
session-action provenance does not record.

Scroll's keyboard policy moves to `requiredWhenAvailable`. The probe costs a live AX fetch, but
gating it on a healthy tree left the first scroll of a session swiping under the keys, which is the
failure this is for. Every scroll logs its decision, including the two ways it avoids reading the
keyboard at all.

Scroll no longer shares `frameAvoidingKeyboard`, whose 25% fail-open was a tap-reference-frame rule;
that path is unchanged for its remaining callers.

* chore(gates): run the scroll viewport policy tests on the iOS lane

The parity table only detects drift if both halves run in CI. Two of these three were reachable by no
lane, so the Swift half of the table was a local assertion.

* fix(ios): keep the keyboard clip out of the scroll's rotation basis

`resolvedScrollViewport` handed the command one frame for both jobs, and the coordinate rotation reads
a frame's HEIGHT to map a `landscapeRight` native x. Clipping an 834pt landscape viewport to 576pt
therefore moved the dispatched gesture 258pt sideways off the lane the plan had just been built for:
the clip fixed the keyboard and broke the gesture.

The resolved viewport now names both frames, and the gesture comes from one dispatch decision, so the
band the plan is planned inside and the frame its coordinates rotate against cannot be swapped. The
landscape case asserts through that decision and fails on the swap.

* fix(ios): report a scroll's clipped band in its response
2026-09-13 13:55:37 +02:00
Michał Pierzchała cda7522095 fix(ios): gate alert activation on a fresh hittable read (#2506)
* fix(ios): gate alert activation on a fresh hittable read

A snapshot can surface an alert button before the owning app has made it
hittable, and a starved host widens that window. The single, never-repeated
activation tapped into that gap, dropping the button press, riding an
unchanged alert to ALERT_DEADLINE_EXCEEDED with First actions: 0, and
flaking the alert-replacement runner regressions under CI contention.

Wait for a fresh exists+isHittable read before the one activation; still
activates at most once.

* fix(ios): recheck the deadline after the alert hittable probe

The hittable read is a synchronous query that a starved host can complete
past the command deadline. It previously handed back true unconditionally,
so handleAlert tapped once more after the budget was already gone. Only a
read that lands before the deadline buys back the single activation.

Route the read through a unit-test-overridable probe and add a regression
that completes the probe past the deadline and asserts, via the fixture's
own action counter, that no button is activated.

* chore(gates): select the late-hittable-probe alert regression
2026-09-12 18:22:21 +02:00
Michał Pierzchała df0a0f7fd2 perf(package): strip comments from the Apple runner source the npm package ships (#2467)
* perf(package): strip comments from the Apple runner source the npm package ships

The packager copies apple/runner/** into dist/ as Swift source, removing only
its AGENT_DEVICE_RUNNER_UNIT_TESTS blocks, so doc comments and design notes were
downloaded on every install: 71.9 kB of 441.2 kB of packaged runner Swift.

Add a lexical scanner for the removal. A regex cannot do this: `//` and `/*`
open a comment only in code position, raw literals move their own delimiter and
escape with the `#` count, interpolation segments hold code and further
literals, and Swift block comments nest. A construct the scanner cannot account
for throws at packaging time instead of shipping Swift that does not compile.

* fix(package): keep Swift regex literals out of the comment scanner

`#/foo//bar/#` is a valid extended regex literal with no comment in it, but the
scanner only knew the `#"` raw-string family, so it read the literal's `//` as a
line comment and shipped `let pattern = #/foo` — Swift that does not compile.
Add `#/…/#` and `##/…/##` as a literal context: matching `#` counts, the
single- and multi-line forms, Swift's own-line rule for a multi-line closing
delimiter, and the `\/` escape that keeps one from closing early.

Bare `/…/` literals stay unresolvable, because the same `/` opens a comment,
divides, and starts a regex literal, and only the parse separates them. Where
one could begin — an expression position whose `/` is not followed by a space,
a tab or `)` — packaging throws by file and line instead of rewriting bytes it
cannot prove are code. Divisions (`width/2`, `Double(3)/Double(4)`), the
recording scripts' shebang and `(/)` keep flowing through.

* fix(package): keep the packaged runner source on the checkout's line numbers

`dist/apple/runner/**` is the Swift a user's `xcodebuild` and the runner name a
file and line in (it lands in runner.log), so those numbers are only worth
reading if they point at the same line of `apple/runner/**`. Both rewriting
passes now empty the lines they remove instead of deleting them: comment removal
(889 lines, 889 B) and the pre-existing unit-test `#if` block strip, which was
moving everything below a block by up to 883 lines (3,737 lines, 3,737 B).

`dist/apple/runner/` 555,907 B -> 488,635 B (-67,272 B, -12.1%); its Swift alone
441,196 B -> 373,924 B (-15.2%). Parity costs 4,626 B of the 71,898 B the
previous head saved.

Nothing in the repo compiles the packaged source, so a mis-lex that failed to
throw would ship Swift that does not build and no gate would see it. Add
`pnpm check:packaged-runner-swift`: it packages into a throwaway root and asserts
line-count parity plus the line of every declaration each packaged file still
carries, then runs `swiftc -parse` over all 44 files. The parse half reports
itself skipped where no Swift toolchain exists, so the gate is declared on the
macOS lane, where both halves run.
2026-09-11 12:00:02 +02:00
Michał Pierzchała bd42b2602f fix(ios): serve regular --depth from every snapshot backend (#2431)
* fix(ios): serve regular --depth from every snapshot backend

A regular depth-capped request was refused on every runner backend but the
recursive tree: the query sweep past depth 1 and private AX at any depth
returned no capture, so a plan pinned or deferred to private AX (custom
actions, a private AX verdict on the session, the XCTest channel penalty)
fell through to the synthetic sparse root, which the daemon then rejected as
"regular iOS snapshot presentation requires a valid viewport".

Presentation already applies the presented-depth cut to whatever hierarchy a
backend acquired, and a depth-capped regular capture is a subset of the
unscoped one from the same backend, so the refusal protected nothing the
unscoped answer did not already disclose through truncated/effectiveDepth.
Delete the gate, declare private AX as regular-depth=presentation-cut, and
record the rule in ADR 0004.

Closes #2403

* test(ios): prove a private-AX-pinned plan serves regular --depth through acquisition

The presentation-package test passes with the old backend depth gate restored,
because it calls presentation directly. This runner-bundle test pins private
AX, asks for regular depth 1 against the launched host app, and requires the
plan to reach acquisition and presentation: a private-ax verdict that is not
sparse, more than one node, a real root rect, and a payload no larger than the
unscoped capture from the same backend. With the gate restored the plan logs
SNAPSHOT_BACKEND_DEPTH_UNSUPPORTED and returns the zero-rect sparse root, and
the test fails.
2026-09-10 11:21:01 +02:00
Michał Pierzchała 0dfd65f6a2 perf(ios): speed up deep snapshots and keep first taps reliable (#2414)
* perf(ios): recover deep snapshots and isolate optional tap probes

* chore(gates): enforce snapshot assets and optional probe lifecycle

* fix(ios): preserve capture bounds and local probe recovery

* chore(gates): validate base package assets with its own policy

* chore(gates): verify recovery failures respect launch observation policy

* fix(ios): fail closed on unknown snapshot frontier completeness
2026-09-09 18:24:15 +02:00
Thiago Brezinski 78cfc4505d fix(ios): avoid duplicate alert routing queries (#2398)
* fix(ios): resolve alerts without duplicate modal routing probes

* chore(gates): exercise alert dispatch and deadline on iOS PRs
2026-09-08 14:29:05 +02:00
Michał Pierzchała 1f9d940bff refactor(capture-kit): complete ADR 0019 end state — relocate snapshot and recording zones (#2385)
* refactor(capture-kit): relocate snapshot and recording zones into capture-kit

Move the ADR 0019 end-state capture zones into @agent-device/capture-kit:

- src/snapshot/** -> packages/capture-kit/src/snapshot/** (presentation,
  freshness, scroll-edge-state, ios-snapshot-runtime, android occlusion)
- src/recording/** -> packages/capture-kit/src/recording/**
- src/core/snapshot-{chrome,state,tree-ingestion,node-lookup}.ts ->
  packages/capture-kit/src/
- src/snapshot-quality/ test -> capture-kit presentation tree (directory
  retires with its last file)

Pure renames: import re-pointing and gate updates follow in the next commit.
The snapshot-desktop-surface test parks in src/__tests__/ because it pins
the root eager-import-closure walker.

* refactor(capture-kit): re-point capture and recording consumers to the new subpaths

Rewires every consumer of the relocated snapshot/recording modules to the new @agent-device/capture-kit subpath exports, adds the 23 subpath entries to the capture-kit exports map, fixes the moved recording-scripts test's __dirname-relative paths for the deeper location, and records the completed migration in ADR 0019's end state.

* chore(gates): align layering, mutation, fallow and CI gates with the capture-kit relocation

Moves the executable-policy roots, presentation-owner constant, zone ranks, authority fixture, mutation sharding globs, stryker aliases, fallow baselines and the iOS workflow's android-owned paths-ignore entry onto the new packages/capture-kit paths, and extends the planted-red coverage to the new presentation-owner subpath.

* chore: point capture-domain source-of-truth comments at the relocated capture-kit modules

* test: point shutdown recording mock at capture-kit and cover interactor acquisition presentation

* test(capture-kit): update upstream presentation test imports

* chore(gates): follow relocated snapshot assembly in R74

* test(daemon): freeze prewarm deadline assertion clocks
2026-09-08 12:41:39 +02:00
Michał Pierzchała 89c7536850 feat(ios): support explicit iOS simulator keychain reset (#2345)
* feat(ios): support explicit iOS simulator keychain reset

`settings clear-app-state` never touched keychain-backed credentials
(e.g. Firebase auth), so a customer's fresh-install reset via the CLI
left an app signed in when their in-app reset button did not (#2282).

simctl exposes no per-app keychain reset, only a whole-simulator one
(`simctl keychain <device> reset`), so this ships as a separate,
explicit `settings reset-keychain clear` command rather than folding
it into `clear-app-state` — callers opt in knowing the scope is the
whole simulator, not just the app under test.

Split the pre-existing `apps.test.ts` and `snapshot-handler.test.ts`
suites along the `app-settings.ts`/`snapshot-settings.ts` modules they
actually mirror, since both were already over the test-file-size
tripwire and could not grow further.

* fix(ios): reject extra reset-keychain arguments and add live-tested keychain fixture

settings reset-keychain clear <extra-arg> silently dropped the extra
argument in both the CLI reader and the direct-daemon parser, so a
caller expecting per-app scoping could get a whole-simulator wipe
without any signal something was off. Reject it instead in both
places, with tests proving no settings mutation happens.

Also add a small keychain-backed "auth" fixture to the test-app's
automation lab (expo-secure-store) so the settings reset-keychain
guarantee has a real regression surface: authenticate, verify the
credential survives clear-app-state and a plain relaunch, then verify
reset-keychain actually clears it. Validated live against a disposable
iOS simulator.

* fix(ci): stop a bare gradle.properties append from corrupting the last line

expo prebuild's generated android/gradle.properties has no trailing
newline, so `echo "org.gradle.jvmargs=-Xmx4g" >> gradle.properties`
appended directly onto its last line instead of a new one, producing
expo.inlineModules.watchedDirectories=[]org.gradle.jvmargs=-Xmx4g.
Gradle's JSON.parse of that property then fails at configure time,
before any real compilation runs -- the exact "Process 'command
'node'' finished with non-zero exit value 1" failure this branch hit
on Android Release and the Smoke Tests fixture-app fallback build.

This was a dormant bug: the Android build-cache job only runs on a
fingerprint miss, and no PR had changed the test-app's native
dependencies in a while. Adding expo-secure-store (#2282's keychain
fixture) was enough to trigger it. Reproduced locally against a clean
install with the exact CI script, confirmed the corrupted property,
and confirmed the printf-based fix builds cleanly (870/870 tasks).
2026-09-06 12:51:51 +02:00
Bills Booth 27a97ee619 fix(ios): confirm alerts without repeating activation (#2326) 2026-09-06 10:00:02 +02:00
Michał Pierzchała 80997b6bf1 fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal (#2315)
* fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal

Two CI flake families on main and PRs since 2026-09-03.

iOS Smoke, `is absent ... capture was truncated` (7 of 13 failures): the
runner's stampedSnapshotPayload set `truncated: true` on every non-healthy
capture, so a complete private-AX tree taken while the XCTest channel was
penalized as slow (the normal state on a loaded CI host) was reported as
truncated. Nothing consumed that until the strict absence assertion (#2245)
refused truncated captures. `truncated` now tracks completeness only:
payload truncation, a depth-limited capture, or a sparse terminal payload.
The E2E conformance helper asserted the old conflation and now asserts
`truncated === false`; a runner unit test pins the new contract and joins
the targeted list in ios.yml.

Android Smoke, `get text id="automation-alert-result"` selector miss (5 of
5 failures): #2260 replaced a polling wait with a one-shot read right after
`alert dismiss`, and Android's `alert accept|dismiss` returned as soon as
the button was pressed, while the dialog window was still the only thing in
the accessibility tree. They now poll until the same dialog is gone (a
different alert taking its place counts as dismissed), bounded by the
existing action budget, else fail with "did not dismiss the visible alert"
like the iOS runner already does.

* test(provider): model Android dialogs that leave the tree after the alert action

The scripted Android alert scenarios served the same dialog to every
capture, which encoded the old return-after-press behavior; alert
accept/dismiss now confirm the dialog is gone, so a dialog that never
leaves is the failure it should be (covered by a new scenario). The
fixtures now hide the dialog once its button is tapped or Back is sent,
the way the ANR recovery scenario already did.

* test(e2e): wait for the alert outcome before reading it; dump evidence for any failed step

The Android smoke still missed `id="automation-alert-result"` on CI right
after a confirmed dismissal: the daemon opened a fresh helper session for
that read and its 2s capture had no such node, while the same one-shot
read passes locally in 150ms. The fixture's re-render after the button
callback is app timing, so the scenario waits for the outcome text (the
polling landmark #2260 removed) and then pins it to the canary element.

The harness kept only a screenshot, and only for wait timeouts, so the
tree that produced a selector miss was never in the artifacts. Every
unexpected step failure now writes failed-step-N.png and
failed-step-N-snapshot.json next to failed-step.txt.

* test(provider): move the Android alert scenarios and dialog fixtures out of android-lifecycle

The test-file size ratchet rejects growth in android-lifecycle.test.ts
(1,597 lines at the merge-base), and the dialog re-check work added a
scenario there. The alert scenarios now live in android-alert.test.ts
and the scripted dialog surfaces they share with the ANR scenarios in
android-dialog-fixtures.ts; the lifecycle file drops to 1,260 lines.
2026-09-05 23:15:21 +02:00
Michał Pierzchała cf83afb9c9 feat(ios): route Simulator snapshots through AX bridge (#2279)
* feat(ios): route simulator snapshots through AX bridge

* fix(ios): preserve snapshot fallback lineage

* fix(ios): keep regular depth in presentation

* perf(ios): reuse process-verified snapshot targets

* fix(ios): refuse snapshots beneath another foreground owner

* test(ios): bound native setup and isolate runner reset

* test(ios): synchronize helper crashes with request dispatch

* test(ios): exercise foreground guards through native capture

* chore(gates): run native snapshot ownership regression on iOS CI
2026-09-05 23:05:58 +02:00
Michał Pierzchała 7a2d48d160 perf: bundle runtime dependencies and report full install size (#2310)
* perf: bundle runtime dependencies and report full install size

* refactor: remove unused size report breakdowns
2026-09-05 21:38:20 +02:00
Michał Pierzchała 9d9e93a321 ci: avoid unrelated Apple runner cache invalidation (#2303) 2026-09-05 21:10:39 +02:00
Michał Pierzchała 006f2d9f60 chore(gates): layering baselines ratchet against merge-base (#2299)
* refactor(layering): ratchet R6, R9 and R10 against the merge-base tree

R6 type-spine inversions, R9's largest type cycle and R10's R7 ownership
pressure now compare the working tree with the same measurement taken over
the merge-base with origin/main, read through the shared committed-tree
reader (one git ls-tree, one git cat-file --batch, no second checkout).
Growth still fails with the same message shape, a shrink needs no edit, and
no change can bank headroom by leaving a number above the tree.

R9's per-zone check gains membership from the reference, so the overflow
message names the file that joined instead of listing the whole zone.

* chore(gates): delete the R6, R9 and R10 pins the merge-base now supplies

TYPE_INVERSION_BASELINE, LARGEST_TYPE_CYCLE_ZONE_CEILINGS, TYPE_CYCLE_BASELINE
and DAEMON_MODULARITY_BASELINE.sessionState were the hand-edited references
these three ratchets compared against. The merge-base measurement replaces
them, so there is no number left to leave above the tree and no entry to raise.
externalDaemonTypesImporters stays: it names files, not a count.
2026-09-05 20:48:46 +02:00
Michał Pierzchała 5bb3ea3b2a feat(ios): productionize Simulator AX snapshot bridge (#2277)
* feat(ios): productionize Simulator AX snapshot bridge

* fix: address Simulator AX bridge review comments

* docs: refresh Simulator AX evidence

* fix: address new Simulator AX bridge review comments

* docs: record public snapshot source timings

* fix: preserve size report helper on base checkout

* fix: allow base packages without snapshot bridge

* fix: close simulator snapshot source ownership gaps

* docs: explain simulator bridge language choice
2026-09-04 13:56:22 +02:00
Michał Pierzchała 941ca0e7e0 ci: enforce the image-size parser mitigation through a test-app gate (#2269) 2026-09-03 21:36:11 +02:00
Michał Pierzchała 4e9820e46e ci(android): wait for the producer's platform job and keep the fallback build alive (#2261)
Classify the fixture producer by its platform job (Android Release / iOS
Release) instead of the run's aggregate status: the run flickers to
"queued" in the gap between its fingerprint job and its platform job,
which made the Android consumer bail into an inline build almost
instantly (run 33550746596). Both queued and in_progress on the
platform job now mean keep waiting.

Raise android.yml's wait-for-artifact-seconds to 1800s (matching the
iOS workflow's own budget) so a real wait can play out now that the
premature "queued" bail is gone.

Raise the Android Gradle daemon heap (org.gradle.jvmargs=-Xmx4g) for
both the producer build and the inline fallback build: the producer's
own Android Release job OOM'd in :app:compileReleaseArtProfile (run
33728318738), and both prior inline fallbacks OOM'd in
:app:mergeDexRelease.
2026-09-03 14:30:57 +02:00
Michał Pierzchała ed76c9c848 fix(ci): fingerprint the test app from its own directory (#2256)
@expo/fingerprint hashes process.cwd() as the project root, but
resolve-artifact-name.sh invoked it from the workspace root: the fixture
cache key was the whole repo's fingerprint, not examples/test-app's native
sources. Run fingerprint:generate with cwd examples/test-app instead; the
script's stdout contract (fingerprint.<hash>.<platform>) is unchanged.
2026-09-03 11:19:28 +02:00
Michał Pierzchała 259cc62a0b ci(ios): give the pan-duration replay a budget that absorbs a runner rebuild (#2250)
All 5 recent failures of the gesture pan-duration smoke replay step were
the replay's open --relaunch running a full xcodebuild build-for-testing
after a spurious runner cache_metadata_mismatch (48-61s build + ~40s
launch), which consumed the 60s per-replay budget before --retries 2
could fire. Pass --timeout 180000 to the test command, which overrides
the script's context timeout=60000 per attempt without touching the
shared .ad file.
2026-09-03 08:01:45 +02:00
Michał Pierzchała 6a24dc1b2d chore(depgraph): stop re-deriving the layering inversion baseline (#2241)
* chore(depgraph): stop re-deriving the layering inversion baseline

The report's typeInversionsByPair and the gate's checkTypeInversions run the
same loop over the same resolveImportEdges output, so asserting that the
report reproduces TYPE_INVERSION_BASELINE over the real tree checked one
code path against itself. Replace the tree-wide cross-check with a synthetic
test of the report's own counting rule (raw edges, once per file pair).

* chore(gates): retitle the depgraph gate as the report's model tests

The Layering Guard step no longer claims to agree the report with the gate;
it runs the depgraph model and blast-radius tests, which the gate manifest
requires a registered check to own.

* docs: clarify inversion ratchet ownership
2026-09-02 21:31:54 +02:00
Michał Pierzchała b15121ffc8 test(ios): establish snapshot convergence baselines and permanent evidence (#2204)
* test(ios): add snapshot convergence evidence harness

* fix(ios): satisfy benchmark CI guards

* fix(ios): constrain benchmark proxy routes

* fix(ios-benchmark): enforce cell admission evidence

* fix(ios-benchmark): protect benchmark state ownership

* fix(ios-benchmark): use proxy port flag

* fix(ios-benchmark): let proxy choose an ephemeral port

* test(ios-benchmark): keep CLI process seam local

* fix(ios-benchmark): parse proxy startup envelope

* fix(ios-benchmark): bind proxy lease to simulator

* fix(ios-benchmark): keep fresh proxy CLI sessions isolated

* fix(ios-benchmark): preserve async timeout evidence

* docs(ios-benchmark): retain exact-head evidence

* test(ios): reveal offscreen alert fixture controls

* test(ios): reset alert between relaunch samples

* test(ios): admit native alert snapshots

* docs(ios): publish snapshot convergence corpus

* chore(ios): format benchmark evidence

* fix(ios-benchmark): admit proxy fixture anchors

* docs(ios): republish exact-head benchmark corpus

* fix(size): make publish asset evidence hermetic

* style(size): format package evidence test

* test(size): update publish preparation contracts

* fix: retire stale utils layering zone

* test: pin shared publish asset owner

* test: verify preserved size reporter closure

* fix: move mutation ownership to snapshot module

* test(ios): add snapshot convergence evidence harness

* fix(ios): satisfy benchmark CI guards

* fix(ios): constrain benchmark proxy routes

* fix(ios-benchmark): enforce cell admission evidence

* fix(ios-benchmark): protect benchmark state ownership

* fix(ios-benchmark): use proxy port flag

* fix(ios-benchmark): let proxy choose an ephemeral port

* test(ios-benchmark): keep CLI process seam local

* fix(ios-benchmark): parse proxy startup envelope

* fix(ios-benchmark): bind proxy lease to simulator

* fix(ios-benchmark): keep fresh proxy CLI sessions isolated

* fix(ios-benchmark): preserve async timeout evidence

* docs(ios-benchmark): retain exact-head evidence

* test(ios): reveal offscreen alert fixture controls

* test(ios): reset alert between relaunch samples

* test(ios): admit native alert snapshots

* docs(ios): publish snapshot convergence corpus

* chore(ios): format benchmark evidence

* fix(ios-benchmark): admit proxy fixture anchors

* docs(ios): republish exact-head benchmark corpus

* fix(size): make publish asset evidence hermetic

* test(size): update publish preparation contracts

* fix: keep git-state gates out of mutation sandboxes
2026-09-01 21:39:05 +02:00
Michał Pierzchała 1826b2e68b refactor(ios): integrate runner with snapshot engine (#2214)
* refactor(ios): integrate runner with snapshot engine

* fix(ios): preserve macOS runner snapshots

* refactor(ios): keep runner presentation device-aware

* fix(ios): validate runner scroll presentation

* fix(ios): close presenter package boundaries

* fix(ios): preserve snapshot source lineage

* test(ios): colocate snapshot engine coverage

* fix(ios): settle post-merge audit checks

* test(ios): fix manifest parity lint

* refactor(ios): simplify runner source walk

* test(ios): cover shared package source fixture

* fix(ios): close post-merge audit gaps

* perf(ios): avoid bundling acquired snapshot path
2026-09-01 18:36:09 +02:00
Michał Pierzchała a8ee397168 test(ios): add snapshot engine conformance gates (#2213)
* test(ios): add snapshot engine conformance gates

* test(ios): align differential acquisition inputs

* fix(ios): gate Swift differential on macOS

* test(ios): keep differential coverage host-aware

* test(ios): own snapshot differential on macOS
2026-09-01 15:59:31 +02:00
Michał Pierzchała 42dc9adb5d fix: address security scanner findings (#2182)
* fix: address security scanner findings

* fix: close image-size parser review gap

* test: prove zero-length image box regressions

* fix: keep fixture fingerprint output machine-readable

* test: align fixture fallback with fingerprint owner
2026-08-31 20:38:44 +02:00
Michał Pierzchała f3aabff12d refactor(snapshot): move Android helper presentation (#2184) 2026-08-31 19:01:39 +02:00
Michał Pierzchała a6232e51cf refactor: prune platform split residue (#2123) 2026-08-29 13:10:47 +02:00
Michał Pierzchała c7f42ccedc refactor: move Android family behind package exports (#2117)
* refactor: move Android family behind package exports

* fix: address Android W5 review feedback

* fix: update relocated routing fixture assertion
2026-08-28 13:02:28 +02:00
Michał Pierzchała 838ed223b5 refactor: move W6 platform families behind package facades (#2116)
* refactor: move W6 platform families behind package facades

* fix: address W6 loading and composition review
2026-08-28 12:46:39 +02:00
Michał Pierzchała 437465f37b ci(1874): declare the diagnose lane and read its iterations honestly (#2059)
The loop that #1874 is investigated with could not tell the truth about itself.
It classified every non-`passed` iteration as a stall, which after #2035 gave the
looped test an XCTSkipIf meant an environment flip would report a 100% stall
rate; it captured cadence only for failures, though an absorbed episode now
passes; and it read its logs with shell pipelines whose exit status means "did
this match", so an iteration that legitimately matched nothing killed the job
before it could be summarized.

scripts/diagnose-1874-iteration.ts reads one iteration: xcodebuild's own verdict,
the `type-all` duration, and the cadence worth keeping. A nonzero exit outranks a
green measured test — in `pair` mode the neighbour or the runner can fail while
the measured test passes — and a run that produced no verdict is named as ours
rather than counted as a stall. The workflow gains the #1781 lane declaration it
never had. Its kill criterion names #2080, which the loop can now serve rather
than merely claim to: the looped test is a dispatch input, so the fill route that
#2080 traces loops the same way. One test pins the contract the script cannot
check about itself — that the workflow hands it the status xcodebuild returned
rather than a literal.

Closes #1874.

Both filed symptoms are resolved. `smoke:form-input` was root-caused and fixed in
#2035: the fixture's placeholder was identical to the value every suite filled,
so `fill` could never be verified on the penalized route — deterministic, not a
flake, and only visible under load because that route is gated on a penalized
XCTest channel. The targeted XCTest is mitigated by the progress-aware commit
budget, with 200 consecutive green loop iterations across two dispatches.

The issue's remaining question — why the input pipeline throttles — is answered
by the second dispatch, and the premise was wrong: it does not. Posting 17
characters took 484 ms and the commit was observed on the first poll, inside an
iteration whose `type-all` measured 14334 ms. The ~12.6 s went to accessibility
round-trips before any character was posted, which is #1105's path, not the
input pipeline's.
2026-08-28 08:33:46 +02:00
Michał Pierzchała 539e848e0c fix(ci): stop ten artifact uploads discarding their hidden paths (#2091)
* fix(ci): stop ten artifact uploads discarding their hidden paths

`actions/upload-artifact` has excluded hidden files and directories by default
since v4.4 (this repository pins v4.6.2), and most diagnostics here are written
under `.tmp`. Ten upload steps across seven files therefore uploaded nothing
from those paths: macos.yml's xcresult bundle, both mutation lanes' reports and
shards, replays-nightly's fuzz output, xctest-nightly's results,
test-app-build-cache's fixture tarball, and 1874-diagnose's per-iteration logs.

Most fail silently, since they pair the omission with `if-no-files-found: warn`
or `ignore`. test-app-build-cache sets `error`, so that one does not.

A structural guard rather than a shared upload wrapper: the wrapper would be a
shallow mirror of the action's options over artifacts with different owners,
while the policy question — a hidden path needs the flag — is one rule that
belongs in one place. Each workflow still declares its own artifact.

test/ci/upload-artifact-hidden-paths.test.ts holds it across every workflow and
composite action, and is red if any single flag is dropped.

* test(ci): scan every YAML shape GitHub accepts, not just top-level *.yml

The guard read `.github/workflows/*.yml` and assumed local actions live one
directory deep as `action.yml`. GitHub also reads `.yaml` for both, and local
actions nest, so a hidden-path upload in any of those shapes passed the gate.

It now walks the `.github` tree recursively for either extension, and a second
test plants the three shapes the old scan missed and asserts all three are
found — executable rather than a one-off manual check. Red against narrowing the
extension, against dropping the recursive walk, and against removing any single
real flag.

Also drops the action-version note under the comment rule in #2087; the version
behaviour belongs in the PR, and the assertion message already says what the
omission costs.
2026-08-27 20:08:45 +02:00
Michał Pierzchała 057b2da233 ci: run coverage in one job again (#2079)
The Coverage lane was split into two matrix shards plus a Coverage Report
job that downloaded both blob reports and merged them. That claimed three
runner slots per PR and put a barrier in front of the merge: the report
job could not start until the slower shard finished, and the blobs it
waited on are tens of MB to upload and download.

One job asks for one slot and reports its own thresholds where it runs, so
the lane finishes when the suite finishes. Everything the split needed goes
with it: the shard/merge switches in vitest.config.ts, the blob reporter
swap, the zeroed per-shard thresholds, and the env blanking that
`test:fuzz-worker` carried only to keep the second leg from inheriting them.
2026-08-27 13:28:54 +02:00
Nicolas Bataille 4b8bcaca60 feat(interaction): accept fill <target> "" as the clear-field primitive (#2066)
* feat(interaction): accept fill <target> "" as the clear-field primitive

Emptying an input was not expressible: `fill` refused the empty string
("Expected text to be a non-empty string"), `type` only appends, and `keyboard`
has no delete verb. Clearing a field before typing is a routine QA step, so the
only route was the app's own clear button or N locale-dependent keyboard delete
presses read out of a snapshot.

`fill <target> ""` now means "replace with nothing". Both platforms already own
the clear half of replace, so this is the validation and reporting that stood in
front of it, not a new interaction:

- `stringField` takes an opt-in `allowEmpty`, used only by `fill`'s `text`.
  `requiredField` still refuses a MISSING text, so `fill @e57` stays an error
  rather than silently erasing the field — `readFillTargetFromPositionals` now
  reports `undefined` for "no text argument" instead of collapsing it to `''`.
  `type` keeps refusing an empty text: appending nothing is not a clear.
- The Apple runner's empty-text early return skipped the clear while reporting
  "typed". For a replacement it now runs `clearTextInput` and verifies the field
  came back empty (secure fields stay unverifiable, as elsewhere).
- Android already clears before typing and skips an empty shell/IME write, but
  its verifier read a cleared field's absent `text` attribute as a mismatch
  against `''`. An empty expectation now accepts null or "".

Whitespace-only text keeps its established per-shape rules; only `''` is new.

Closes #2063

* fix(interaction): fail the empty-fill clear closed on every backend

Addresses the P1 review on #2066, then closes the same fail-open class
on the backends the PR did not reach:

- Android: an empty expectation no longer matches when the verification
  scan observed NO input node at all — actual is null both for a cleared
  field and for a wrong point/lost focus, and three empty samples of
  nothing were a stable success for a clear that never touched a field.
- Apple runner: when the empty-replacement path cannot resolve a clear
  target (including the synthesized first-responder route, whose target
  carries no element), it returns the typed TEXT_INPUT_NOT_FOCUSED
  failure instead of falling through to the vacuous-typing
  verified-success return. Regression runs in the ios.yml XCTest lane.
- webdriver: fill is tap + sendKeys and owns no clear mechanism, so an
  empty fill refuses as UNSUPPORTED_OPERATION before touching the
  device, instead of reporting a clear it cannot perform.
- linux + web coordinate fill: typing zero characters over the
  select-all selection left the old value intact; the empty fill now
  deletes the selection.
- recording: an empty --record-as literal matches inside every string;
  it now parameterizes only the fill's own text field instead of
  rewriting every empty field and empty evidence label in the entry.
  (The session-wide echo registry already excluded empty literals.)
- help: the text-entry topic taught agents that fill "" is not a
  clear-field command; it now states the new contract.

Each new test was observed red against the pre-fix code.

* fix(android): read hint-showing from the helper so a cleared field verifies

Live Pixel 9 emulator, adb-shell channel: clearing the Settings search
field succeeded on the device but reported 'Android fill verification
failed', because a cleared EditText dumps its HINT as text — getText()
returns the hint for an empty field on modern Android, so 'Search
settings' read back as a residual value. This is the same
placeholder-as-value trap the Apple runner already handles with
treatingPlaceholderAsEmpty.

The helper now emits hint-showing (isShowingHintText, API 26+), the
hierarchy parser carries it, and fill verification matches against the
field's VALUE — hint-only text is an empty value, for empty and
non-empty expectations alike. A field whose real value equals its hint
string keeps failing the clear check: only the authoritative flag, never
the text, says it is a hint. Raw uiautomator dumps carry no such fact
and keep the fail-closed behavior.

Live evidence, both admission channels, after this fix: test-ime and
adb-shell clears both report Filled 0 chars with the field back on its
placeholder; the pre-fix adb-shell run failed closed (never a false
success).

* fix(interaction): close the adversarial-review findings on the empty-fill clear

- android adb-shell: the delete burst is sized from the value being
  REMOVED (pre-mutation read; the attempt's cap when unreadable), not
  from the empty incoming text, which sent the 12/24-delete minimums and
  could never empty a field longer than 36 characters.
- android: the unconfirmed soft-success no longer applies to an empty
  expectation — nothing app-formats the empty value, so residue after a
  clear is a failed clear, and the soft-success also skipped the second,
  bigger delete burst.
- android masked fields: an empty expectation accepts an observed masked
  node with no dump text (a masked field WITH content dumps its bullet
  run), so clearing a password field no longer fails after the clear
  worked — matching iOS, where a secure-field clear succeeds unverified.
- find: 'find <q> fill ""' now reaches the fill leaf as the clear
  request on both the CLI reader and the daemon positional parse; a
  MISSING value keeps its refusal at each producer, so the typed
  value: string contract is unchanged.
- maestro export: a recorded clear exports as tapOn + eraseText instead
  of a vacuous inputText: "" (with the 50-character-default warning).
- the missing-text refusals teach the clear form: (use "" to clear
  the field).

Full unit suite green (1061 files); each behavioral fix carries a test
observed red against the prior code.

* refactor(interaction,android): extract the fill parse and shell-attempt branches

The review commits pushed parseFillTarget and fillAndroid over the
complexity gate (13 cyclomatic each). Each fill target shape parses in
its own function sharing one missing-text response, and the adb-shell
attempt (clear sizing + clear + type + verify) moves out of the fill
loop. Behavior-preserving; the existing tests cover every branch.

* refactor(interaction,android): one owner per empty-fill fact

Design pass after review: the missing-vs-empty rule and the observed-
value rule each had several owners; now each has one.

- parseFillTarget decodes ONCE through readFillTargetFromPositionals —
  which already owns shape detection and documents the undefined-vs-''
  contract on DecodedFillTarget — and keeps only what the wire owns:
  versioned-ref admission, the selector whitespace rule, and the daemon
  responses. This deletes the point branch's duplicated slicing, the
  hasFillText guard, and the three per-shape parse functions.
- observedAndroidValue() is the single statement of Android's value
  rule (absent attribute and hint-only text are the empty value); the
  text branch, the match rule, and the masked branch all consume it.
  The masked branch thereby gains the hint-showing collapse it was
  missing, and isAcceptableAndroidFillMatch narrows to plain strings.
- The empty-text-is-clear contract is stated once, on Interactor.fill
  in contracts, instead of implied per backend.

Behavior-preserving except the masked+hint gain; the existing tests
cover every branch (494 Android, 15 fill-target).

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-27 10:59:30 +02:00
Michał Pierzchała edba835365 fix(ci): spawn the differential's agent-device CLI as argv, not one option (#2069)
* fix(ci): spawn the differential's agent-device CLI as argv, not one option

The Conformance Differential nightly has been red since 2026-08-25: every
scenario reported `infrastructure-failed ... agent-device=fail` under

  node: bad option: --experimental-strip-types src/bin.ts

`runAgentDeviceEngine` took the CLI as a single `cliPath` string and spawned
`[cliPath, ...args]`, but AGENT_DEVICE_CLI — which every device workflow sets,
and which is also this runner's default — is a command *line*: a node flag plus
the entry script. Node aborted on the combined token before the CLI loaded, so
the oracle compared nothing on all six scenarios.

Tokenize the variable once, in `resolveAgentDeviceCliArgv` beside the spawn it
feeds, and hand `runAgentDeviceEngine` an argv array. A path containing spaces
stays expressible as a single array element (the property the string signature
was protecting), while a flag plus a script can no longer collapse into one
option.

The fixture the old tests used was a bare `.mjs` path, which cannot tell an argv
from a command line, so the regression test runs the workflow's own shape end to
end — flag plus script — and fails with the exact CI signature without the fix.

* fix(ci): split the differential CLI env into entry path and node flags

Addresses the P1 review on #2069. Tokenizing AGENT_DEVICE_CLI on whitespace
fixed the flags-plus-script shape but broke the other one: an override like
`/tmp/agent device.mjs`, which main passes through intact, became two arguments.
One variable cannot encode both — any delimiter that separates flags from the
entry can also occur inside a path.

So the two concerns become two variables that cannot be confused:

  AGENT_DEVICE_CLI             the entry script — ONE path, never split
  AGENT_DEVICE_CLI_NODE_FLAGS  node flags — split on whitespace, which is exact
                               because a node flag cannot contain a space

Defaults reproduce today's behavior, and the empty string runs an entry that
needs no flags.

The regression now runs through the production route the review asked for —
environment, parseRunnerArgs, runScenario, spawn — rather than calling
runAgentDeviceEngine with a hand-built argv, which cannot see the environment
contract at all. Each direction is pinned by its own case, verified against both
broken implementations: main's unsplit string fails "node flags stay separate
arguments", and the whitespace split fails "a CLI path containing spaces reaches
the spawn unsplit".

The maestro stub stays out of the spaced directory on purpose: runMaestroEngine
still splits its command on spaces, and a spaced stub path would fail these
tests for the other engine's reason.
2026-08-27 10:47:27 +02:00
Michał Pierzchała a904ef0d5d fix(fuzz): run parser cases in a worker process, not the runner's thread (#2053) (#2055)
The unit-lane corpus replay executed adversarial parser cases on worker
threads of the Vitest worker running the test file. A fault in a worker
thread ends its whole process, so a case that faulted killed the test
runner: `[vitest-pool]: Worker forks emitted error / Worker exited
unexpectedly`, with no test, file, or case named. Six of six Coverage
deaths before #1994's split were this one file out of ~1100, and the
uninstrumented second leg it created then lost the same file six more
times in three days.

Cases now run in a worker *process*. The two faults a case cannot report
about itself are both classified from outside it: a case that never
returns is a `hang` (unchanged), and one that ends the process it runs in
is a new `crash` failure carrying the exit code or signal and the tail of
the worker's stderr — the death certificate the lane used to lose. A
sixth self-check target seeds that kind, so a regression in reporting it
fails the harness self-check like every other kind.
2026-08-26 20:40:57 +02:00
Michał Pierzchała 7db5ad73dd fix(ios): grant the text-entry commit wait time against progress (#2035)
* fix(ios): grant the text-entry commit wait time against progress

The synthesized commit wait used a flat 3s deadline, which cannot tell a
throttled simulator input pipeline (characters keep landing, slowly) from a
wedged one (nothing lands) — it condemned both at the same instant and reported
TEXT_INPUT_COMMIT_NOT_OBSERVED over a `type`/`fill` that was still working, on
branches touching no iOS code.

SynthesizedCommitBudget grants time against progress instead: while the observed
value's expected-prefix grows — the same length-only evidence logCommitCadence
already emits — the wait continues, up to a 10s ceiling. A pipeline making no
progress expires at exactly the 3s the flat deadline used, so a wedge is
condemned no later than before. It is a reference type, and the observe/expire
coupling carries a structural guard, because as a struct that coupling would
rest on Swift boxing one captured var and could revert to the flat deadline
silently.

Text-entry readiness' hardware-keyboard fallback also stops returning a
possibly-unfocused element after 0.35s of "no software keyboard seen"; it now
returns only on confirmed focus of the target and re-arms otherwise. And the
keyboard-hidden precondition of
testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden skips rather than fails,
so an environment flip cannot read as a product regression.

The issue's remaining ask — pinning the simulator keyboard preference — is
deliberately not done: measured on a dedicated simulator, per-device
ConnectHardwareKeyboard makes no difference to a headless `simctl boot`, which
always shows the software keyboard. See the PR body for the A/B.

Refs #1874 — not a closing keyword on purpose. This is a mitigation; the
unidentified simulator input-throttle mechanism that issue tracks is untouched
here, so it stays open.

* refactor(ios): move the commit-wait budget into the wait itself

Review follow-up. The budget was a detached object tested in isolation, with a
TypeScript parser asserting that two escaping Swift closures happened to share
it — a guard that only existed because the seam was in the wrong place.

The budget is now a local `var` inside `awaitSynthesizedCommitOutcome` and its
replacement counterpart, advanced from the same observation the progress check
already reads, with the clock injected alongside the existing observation and
pacing seams. Recording progress and asking whether time is up are two
statements in one loop, so there is no coupling left to guard.

The detached tests and the TypeScript wiring guard are deleted. In their place,
four sequence tests drive the shipped waits through a hand-driven clock: a
prefix that keeps growing outlives the flat 3s deadline, a frozen prefix is
condemned at exactly 3s, an indefinitely throttled pipeline stops at the 10s
ceiling, and a value churning between two lengths buys no time. Verified red
first — the two progress tests fail against a no-op `record`, and the two
unchanged-behavior tests stay green.

* fix(ios): grant the text-entry commit wait time against progress

The synthesized commit wait started its clock before reading the field's
placeholder, and that read is an AX round-trip which takes seconds on exactly
the loaded host this budget exists for. Slow setup therefore spent the budget:
with a 3.5s placeholder read the first observation already exceeded the 3s
stall budget, so `type` reported TEXT_INPUT_COMMIT_NOT_OBSERVED after a single
poll — sooner than the flat deadline this replaced, in the one condition it was
written for.

The budget is now two durations, and only the poll loop starts it, from its own
first `now()`. Passing a pre-loop timestamp is no longer expressible. The poll
also takes one clock sample instead of two, so the instant an observation is
recorded at is the instant it is judged against.

testCommitWaitBudgetStartsAtTheLoopRatherThanBeforeIt pins it: 60s of setup
before the wait must still leave the full stall budget. Verified red against a
deadline started outside the loop.

* fix(test-app): stop the form fixture placing its own placeholder in every fill

The `smoke:form-input` half of #1874 is not the commit deadline. This PR's own
iOS lane reproduced it (run 32889322172) and the trace settles it: `wait start
expectedLen=12`, then zero `[DEBUG-1874] poll` lines, then `wait
outcome=notObserved elapsedMs=3608`. The wait never polled — it returned from
the `textMatchesPlaceholder` guard, which refuses before polling because an
empty text field renders its placeholder AS its accessibility value, so a match
cannot prove a commit.

`field-name`'s placeholder was "Ada Lovelace" and every checkout-form suite
fills exactly "Ada Lovelace"; `field-email` had the same collision with
"ada@example.com". Twelve fills across eight files, so `fill` into those fields
is unverifiable by contract. It looked intermittent only because the
synthesized-replacement route is gated on `xCTestChannelPenalized` — it fires
when the host is loaded — which is also why re-running a failed job on the same
commit reproduced it identically.

The collision also made the read-back assertions vacuous: `assertJsonContains(
name, 'Ada Lovelace')` is satisfied by an empty field rendering the placeholder.

Fixed in the fixture rather than in the values, because frozen replay-compat
corpora carry the same fills and must not be edited.
fixture-fill-placeholder-collision.test.ts guards the class: it fails on any
repository fill whose value equals the target field's placeholder.

* refactor(ios): drop the fill/placeholder source guard and flatten the commit deadline

Review: the 83-line guard was a source-reconstruction test, not a fixture
invariant. It regex-parsed JSX and two literal fill spellings and duplicated the
Swift trim/equality rule in TypeScript, so it could stay green while its "every
fill" claim was false — expressions, variables, typed clients and unlisted roots
are all outside what a regex can enumerate. The owning evidence already exists:
the Swift tests prove a placeholder-equal AX value is unobservable, and live
smoke:form-input failed on the prior head for exactly this collision. Deleted;
the two placeholder changes stay.

Same pass over the rest of the change, for the same reason. The commit deadline
was a budget value type, a nested Deadline type and a factory method; it is now
one flat struct the poll loop constructs, with the two durations as defaulted
parameters. Production call sites name no budget at all, tests name one only
when they are asking about time, and SynthesizedCommitBudget.standard and the
tests' unboundedCommitBudget both disappear.

* refactor(ios): split the text-entry readiness and commit-wait seams

Review: the change grew three files past their budgets. Splitting them along the
seams they already had, no behavior change.

RunnerTests+TextEntry.swift (607) keeps the vocabulary, field clearing and value
reading at 259; everything that decides "which element is about to receive text,
and has it taken focus" moves to RunnerTests+TextEntryReadiness.swift at 354.

RunnerTests+SynthesizedTextEntry.swift (503) keeps the private-XCTest synthesis
boundary, the replacement route and the route policies at 356. The commit wait
moves next to the deadline that bounds it: the two waits, the observation and
pacing they poll through, and the value-free cadence line that path may log now
sit together in RunnerTests+SynthesizedCommitDeadline.swift at 206. That also
puts every line touching the polled field value in one file, so
apple-runner-log-redaction.test.ts guards a single surface — its path constant
moves with it.

The deadline's clock and sequence tests leave the policy tests (641 -> 494) for a
sibling RunnerTests+SynthesizedCommitDeadlineTests.swift, which gains the
replacement-route case the review asked for: a growing prefix carries the wait
past the 3s stall budget and the 10s ceiling is what ends it. The injected clock
is now defaulted, so only a test actually asking about time names it.

* refactor(ios): split text-entry target acquisition from readiness

Review residual: the readiness extraction was 354 lines and still owned two
questions. Acquisition — the one-shot tap witness, post-tap stabilization, both
focusTextInputForTextEntry entry points and the refresh point — moves to
RunnerTests+TextEntryFocus.swift (206). Readiness keeps the waits, the keyboard
signals they read and the focus corroboration (158).

The dependency is one-way: acquisition asks readiness, never the reverse, so
waitForTextEntryReadiness and keyboardBecameVisible lose file-private scope and
nothing else does.
2026-08-26 16:50:37 +02:00
Michał Pierzchała 72cae2bc72 refactor(apple): colocate the XCUITest runner client into packages/platform-apple (#2040) (#2050)
* refactor(apple): colocate the XCUITest runner client into packages/platform-apple (#2040)

Moves src/platforms/apple/core/runner/ (34 modules + apple-runner-platform.ts and
the 30 runner test suites) into packages/platform-apple/src/runner/ — Apple
mechanics live in the Apple package. Host capabilities (exec, diagnostics,
retry, process probes, locks, Apple tooling, physical-device control) enter
through the package-owned AppleRunnerHost port; the root composition module
src/platforms/apple/core/runner-client.ts constructs the client exactly once
and re-exposes the bound operations under their historical names.

R13 admits the transitional state deliberately: the family exports its root
façade plus exactly the enumerated ./runner, ./runner/client, and
./runner/test-host subpaths; the ./runner façade subpath is the recorded #1983
seam for unmigrated root consumers; ./runner/client has one composition root
and ./runner/test-host one vitest installer; the runner subtree may own its
cache files and sockets while raw process primitives stay banned. When #1983
completes, the subpaths and every subtree exemption are deleted and the family
returns to a single implementation-lazy façade export.

* docs(adr): model the runner subtree as a durable platform-owned facet

Review correction on #2050: the sunset story attributed the runner-consumer
migration to #1983, which owns snapshot/presentation vocabulary — not the
runner's daemon/root consumers — so that event cannot delete the ./runner
subpaths or the subtree exemptions. Reword ADR-0019, R13, and the gate
comments: the facet is the intended ownership model, its seam is enumerated
and pinned (exact export list, one client composition root, one test-host
installer, raw-process ban, eager-closure pins), and the seam narrows only
if a real runner-consumer migration retires the direct consumers. The
declaration mechanism stays apple-specific until another family needs a
mechanics facet. No behavior change; identifiers and comments only.
2026-08-26 15:53:01 +02:00
Michał Pierzchała b40debfcd8 fix(ci): skip release instead of erroring when both fixtures are cached (#2036)
* fix(ci): skip release instead of erroring when both fixtures are cached (#2034)

map(select(.build)) yields an empty include list when both the iOS and
Android fingerprints already have a trusted artifact, and GitHub Actions
rejects an empty strategy.matrix at the workflow level -- so release was
never created and the run was marked failure on every push since #1996
merged. Publish has-work alongside matrix and gate release on it, so the
both-cached steady state now completes with release skipped instead of
erroring the whole workflow.

* test(ci): fold has-work regression into the existing fingerprint test

Reviewer feedback on #2036: the standalone four-case test duplicated the
harness above it and only two states are meaningful for this regression.
Reuse the same parsed workflow, temp dir, resolver stub, and Node stub;
keep neither-cached (both platforms, has-work=true) and both-cached
(empty matrix, has-work=false, release gated). Drops the single-cache
permutations, which exercise #1996's unchanged filtering rather than
this fix.

* test(ci): cover the single-cache matrix cardinality (#2036 review)

Reduced coverage to 0-cached and 2-cached, leaving the 1-cached
cardinality unchecked -- a mistaken \`length > 1\` in the has-work
check would pass while wrongly suppressing a valid single-platform
build. Generalize the Node stub to report caching per artifact-name
suffix and add the iOS-cached case to the same reused harness.

* test(ci): extract the has-work value instead of comparing raw output lines

Thermo-nuclear review: matrix was already parsed out of its GITHUB_OUTPUT
line (prefix stripped, JSON-parsed), but hasWork returned the raw
"has-work=true" line, so assertions compared against a redundant
'has-work=true' string instead of the actual value. Slice the prefix
the same way matrix does.
2026-08-26 07:49:33 +02:00
Michał Pierzchała a830ac8df2 feat: add Linux command evidence lane (#2017)
* feat: add Linux command evidence lane

* fix: assert Linux find result shape

* fix: read Linux find result envelope

* fix: reset Linux calculator before diff

* fix: release Linux session before reset

* fix: guard Linux evidence session reset

* fix: forward Linux evidence timeout

* fix: tighten Linux evidence assertions

* fix: preserve Linux replay session identity

* fix: close Linux replay session before reset

* fix: share Linux evidence daemon state

* fix: keep Linux swipe evidence in bounds

* fix: keep Linux artifact gap honest
2026-08-25 07:56:13 +02:00
Michał Pierzchała d97a628e38 fix(ci): make the two rg-based static checks actually run (#2006)
* fix(ci): make the two rg-based static checks actually run

ripgrep is never installed on ubuntu-latest, so both `rg` assertions in
the Lint & Format job failed with "command not found" (exit 127) on
every run. `if rg ...; then ... fi` cannot distinguish that from "no
matches" (exit 1) — both read as false, so each step silently passed
without its assertion ever executing. The DI-seams check had 7 live
violations it never reported.

Rewrite both against `grep`, which every runner ships, with match/
no-match/error exit codes handled explicitly so a broken scan fails
the lane instead of reading as a pass, plus a zero-tracked-files guard
so a renamed directory can't quietly go uncovered.

The DI-seam pattern also gets narrower to drop two classes of false
positive surfaced by actually running it: `typeof fetch` (fetchImpl?/
fetch? seams inject the one global with no module boundary vi.mock can
intercept; auth-session.ts/cloud-profile.ts/daemon-proxy.ts exercise
the seam directly in their unit tests, while CLI-level tests use
vi.stubGlobal('fetch', ...) where the seam isn't reachable — a
deliberate, exercised seam) and `typeof SOME_CONSTANT` in
SCREAMING_SNAKE_CASE (derives a literal union type from a constant,
e.g. interaction-touch-response.ts's dispatchPath field — not an
injectable seam at all).

Fixes #1976

* fix(ci): replace the DI-seam name-based allowlist with an explicit per-site one

Review on PR #2006 (#1976): the previous revision fixed the exit-code
handling but decided which `?: typeof X` matches to ban with a regex
that exempted matches by the *spelling* of the typeof target
(`typeof fetch` always passed, SCREAMING_SNAKE_CASE targets always
passed). That's a name-based semantic allowlist, not ownership: a new,
genuinely test-only `typeof fetch` seam anywhere in the tree would
have silently passed, while an equally legitimate seam under any
other name would still fail.

Add scripts/di-seams: a small, tested TypeScript checker that judges
each match against an explicit, typed, per-site allowlist
(scripts/di-seams/approved.ts) keyed by (file, field name, typeof
target) rather than by name. A triple is exempt only because it was
individually reviewed and named — never because of how it's spelled —
and the gate fails just as hard on a stale approval (one whose triple
no longer matches anything, e.g. after a rename) as on an unapproved
seam, so the list can't silently drift out of sync with the code it
describes.

Moves the DI-seams step in ci.yml to run after Setup toolchain (it's
no longer a toolchain-free text scan); the Swift trailing-comma check
stays where it was.

* fix(ci): register di-seams as a real gate and route it through the tmpdir wrapper

CI caught two things the local (dependency-free) run couldn't:

- oxfmt formatting on the two new files.
- scripts/node-test-tmpdir.test.ts's repo-wide audit: every package.json
  script that invokes `node --test` directly must route through
  scripts/node-test-tmpdir.ts, or a crash/timeout mid-run leaks its
  scratch TMPDIR. check:di-seams now does.
- check:gate-manifest: a package.json script that runs `node --test`
  must be covered by a registered CHECK_CATALOG gate, or the audit
  reports the test suite as run by no lane. Registered 'di-seams' in
  scripts/check-affected/{model,checks}.ts and wired the CI step
  through run-gate like every other structural guard in this job,
  instead of invoking pnpm directly.

Verified locally with node_modules installed: check:di-seams,
check:gate-manifest, check:gate-manifest:test, check:affected:test,
check:layering, check:fallow (scoped to the changed files), format,
lint, and typecheck all pass.

* fix(ci): close the multiline and duplicate-site gaps in the DI-seam scanner

Review round 2 on PR #2006 (#1976):

- findSeamMatches scanned line by line, so a declaration split across
  lines (`field?:` on one line, `typeof X` on the next) was invisible.
  Matching now runs against each file's whole source in one pass —
  `\s` matches a real newline in JavaScript regexes with no extra flag
  needed — with the line number derived from the match's character
  offset.

- checkSeams keyed approval by (file, field, target) alone, so once
  one occurrence of a triple was approved, any further occurrence of
  that same triple anywhere in the file passed too. The key now
  includes the line the match starts on, so an approval names one
  specific declaration, not a recurring pattern. approved.ts expands
  from 5 collapsed entries to the 7 exact sites this closes down to.

Added regression tests planting both gaps directly (a cross-line
declaration, and a second unreviewed fetchImpl?: typeof fetch at a
different line in an already-approved file) and verified both against
the real tree with injected violations, restored cleanly afterward.
Re-ran the full local gate suite (di-seams, gate-manifest, layering,
fallow, format, lint, typecheck) — all green.

* fix(ci): resync approved DI-seam line after merging main

Merging main (#2002) removed an unused import above the approved
dispatchPath?: typeof MAESTRO_COORDINATE_FALLBACK_PATH declaration in
interaction-touch-response.ts, shifting it from line 61 to line 60 —
exactly the location-specific-approval staleness the gate is designed
to catch, just triggered by an unrelated upstream edit rather than a
change in this PR. Updated the approved line to match.

* fix(ci): replace the DI-seam positional table with a code-local approval marker

Review round 3 on PR #2006 (#1976): CI proved the round-2 fix's core
assumption wrong within one push. Keying approval by (file, line,
field, target) made a line number the identity — an unrelated edit
anywhere earlier in a file shifts every approval below it, and that's
exactly what happened: merging main removed an unused import above
the approved dispatchPath declaration, and the gate rejected an
unchanged, already-reviewed line.

Detection is now AST-based (oxc-parser, the same tool
scripts/layering/*.ts already uses) instead of a source-text regex:
any `{ optional: true, typeAnnotation: TSTypeQuery }` node — a
property signature or a bare parameter — is a candidate, which finds
a multiline `field?:\n  typeof X` declaration for free instead of
needing a special case for it.

Approval is a `// di-seam-approved: <reason>` comment immediately
above the declaration, matching this repo's own `//
fallow-ignore-next-line complexity` convention: the marker precedes
what it exempts. approved.ts (the external table) is deleted — there
is nothing left to keep in sync, since the approval travels with the
code it approves. A second, unmarked seam under the same field/target
elsewhere still fails; reordering unrelated code around an approved
declaration no longer touches it.

Added the marker to the 7 real approved sites (fetch-global
injection seams in auth-session.ts/cloud-profile.ts/daemon-proxy.ts;
the literal-type-derivation false positive in
interaction-touch-response.ts) and regression tests proving: a
cross-line declaration is still found, a second unmarked occurrence
of an approved field/target pair still fails, and an unrelated
insertion above an approved declaration no longer breaks it. Verified
against the real tree with an injected multi-line unrelated insertion
before an approved site — still green. Re-ran the full local gate
suite (di-seams, gate-manifest, layering, fallow, format, lint,
typecheck, auth-session unit tests) — all green.

* fix(ci): reject a di-seam-approved marker with no reason text

Review round 4 on PR #2006 (#1976): approvalReason() returned '' (not
null) for a bare `// di-seam-approved:` comment with nothing after
it, and checkSeams() only filtered out null, so an empty marker
silently approved a seam with zero justification — exactly the kind
of unreviewed bypass this gate exists to prevent.

approvalReason() now returns null when the joined reason text is
empty after trimming, so a bare or whitespace-only marker is treated
the same as no marker at all. Added tests for both the model-level
behavior and the end-to-end checkSeams() result, plus verified
against the real tree by injecting a bare-marker declaration and
confirming it's flagged, then restored cleanly.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 21:31:24 +02:00
Michał Pierzchała 02d548dfc9 ci: consolidate CI workflow from 15 jobs to 8 (#1996)
* ci: consolidate CI workflow from 15 jobs to 8

Merge single-gate ubuntu jobs into grouped jobs sharing one checkout and
install: Lint & Format (plus the static text assertions), Repo Guards
(layering/selector/wiring/maestro/mcp-metadata), Compatibility &
Provenance (shared fetch-depth: 0 checkout), Typecheck & Package, and
Integration Tests (absorbs the web smoke with step-scoped env). Every
gate remains an independently named run-gate step; the gate manifest
derives lane ownership structurally.

Drop the Bun setup from FreeRange: @chenglou/freerange's bin is a plain
Node script. It stays GitHub-owned; only the runtime requirement is
retired.

* ci: fold FreeRange into Repo Guards and skip no-op fixture release jobs

FreeRange runs on plain Node now, so its gate joins Repo Guards as the
last step instead of occupying its own worker for the slowest guard.
The fixture release matrix filters to entries that will actually build,
so a cached-fingerprint PR starts zero release runners.

* ci: fold host XCTests into the macOS smoke lane and shard Coverage

The macOS lane now builds one unit-test-flagged runner bundle that both
the host XCTest run and the replay smoke consume, so the host lane no
longer occupies its own macos-26 runner behind a separate queue. The
host lane's file moves with it, and check:xctest-selection follows.

Coverage shards across two runners via blob reports and merges them on
a report job that evaluates thresholds once over the full suite and
produces every coverage artifact. The tmpdir leak check runs per shard,
since a leak lands on whichever runner executed the file.

* ci: drop local shard-smoke artifacts from tracking

* ci: enforce coverage thresholds only on the merged run

A shard evaluates its own half-suite coverage, so the global gate fired
per shard. Shards now report without gating; Coverage Report keeps the
real thresholds over the full merged suite.

* ci: include hidden files when uploading coverage blobs
2026-08-24 16:42:43 +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 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 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 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