mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
t3code/cleanup-claim-tests
188 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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). |
||
|
|
27a97ee619 | fix(ios): confirm alerts without repeating activation (#2326) | ||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
941ca0e7e0 | ci: enforce the image-size parser mitigation through a test-app gate (#2269) | ||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
f3aabff12d | refactor(snapshot): move Android helper presentation (#2184) | ||
|
|
a6232e51cf | refactor: prune platform split residue (#2123) | ||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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>
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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>
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
07023eb202 | fix(ios): separate snapshot actionability from occlusion (#1933) | ||
|
|
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 |
||
|
|
af96c6608d | feat(ios): publish effective snapshot geometry (#1931) | ||
|
|
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 |
||
|
|
2a9a4ee80c |
test: add Linux platform command coverage manifest (#1921)
* test: add Linux platform command coverage manifest * test: address platform coverage review feedback |
||
|
|
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). |
||
|
|
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 |
||
|
|
40e4b0dd3e |
docs(agents): restore and enforce progressive disclosure (#1888)
* docs(agents): restore and enforce progressive disclosure * test(maestro): pin typed selector fallback signal * docs(agents): address progressive disclosure review * docs(agents): restore orphaned traps and close guidance-gate bypasses - AGENTS.md: skills carry a minimal start/routing card; command semantics stay in versioned CLI help (the skills contract enumerates two skills by hand, so prose retains ownership for the rest) - testing.md: restore the two local-only XCTest snags CI never hits (unsigned-bundle policy refusal signature + first-run automation permission) - scripts/gate/routing.ts: record GitHub's 300-changed-file path-filter limit at the paths-ignore assertion it bounds - agent-guidance-contract.test.ts: recurse docs/agents so nested guidance cannot evade the byte budgets while the gate stays green |