diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift index 919af06e87..4fb61c8502 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift @@ -467,9 +467,9 @@ extension RunnerTests { repairMode: TextTypingRepairMode, text: String, fromTapWitness: Bool, - softwareKeyboardVisible: Bool + softwareKeyboardVisible: @autoclosure () -> Bool ) -> Bool { - repairMode == .none && text == "\n" && fromTapWitness && !softwareKeyboardVisible + repairMode == .none && text == "\n" && fromTapWitness && !softwareKeyboardVisible() } /// Length of the shared prefix of two strings. Feeds value-free commit-wait logging: the diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift index 4716e30df6..02b936bda3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift @@ -452,6 +452,14 @@ extension RunnerTests { case unreadable /// The read could not answer, or no input resolves. case unavailable + + func warmupReadIsPending(expectedText: String) -> Bool { + switch self { + case .text(let value): return value != expectedText + case .unreadable: return false + case .unavailable: return true + } + } } /// What a text entry element's accessibility value says about its text. The one owner of the diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift index dd73cae9f6..1db72f1796 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift @@ -252,7 +252,9 @@ extension RunnerTests { } let deadline = Date().addingTimeInterval(TextEntryTiming.warmupValueTimeout) while Date() < deadline { - if editableTextValue(for: resolveTextEntryElement(app: app, target: activeTarget)) == warmupExpectedText { + let observed = textValueRead(for: resolveTextEntryElement(app: app, target: activeTarget)) + if !observed.warmupReadIsPending(expectedText: warmupExpectedText) { + if observed == .unreadable { sleepFor(TextEntryTiming.pollInterval) } return } sleepFor(TextEntryTiming.pollInterval) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift index 5881482c12..5afd1da377 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift @@ -52,28 +52,34 @@ extension RunnerTests { } func testSynthesizedFirstResponderTypeAdmitsOnlyTheBareSubmitKeyAfterAHiddenKeyboardTap() { - let cases: [(TextTypingRepairMode, String, Bool, Bool, Bool)] = [ - (.none, "\n", true, false, true), - (.none, "\n", true, true, false), - (.none, "\n", false, false, false), - (.none, "hardware-keyboard", true, false, false), - (.none, "\r", true, false, false), - (.none, "search\n", true, false, false), - (.append, "\n", true, false, false), - (.append, "hardware-keyboard", true, false, false), - (.replacement, "\n", true, false, false), + let cases: [(TextTypingRepairMode, String, Bool, Bool, Bool, Int)] = [ + (.none, "\n", true, false, true, 1), + (.none, "\n", true, true, false, 1), + (.none, "\n", false, false, false, 0), + (.none, "hardware-keyboard", true, false, false, 0), + (.none, "\r", true, false, false, 0), + (.none, "search\n", true, false, false, 0), + (.append, "\n", true, false, false, 0), + (.append, "hardware-keyboard", true, false, false, 0), + (.replacement, "\n", true, false, false, 0), ] - for (mode, text, fromTapWitness, softwareKeyboardVisible, expected) in cases { + for (mode, text, fromTapWitness, softwareKeyboardVisible, expected, expectedReads) in cases { + var keyboardReads = 0 + func readKeyboardVisibility() -> Bool { + keyboardReads += 1 + return softwareKeyboardVisible + } XCTAssertEqual( Self.shouldUseSynthesizedFirstResponderType( repairMode: mode, text: text, fromTapWitness: fromTapWitness, - softwareKeyboardVisible: softwareKeyboardVisible + softwareKeyboardVisible: readKeyboardVisibility() ), expected, "mode: \(mode), text: \(text.debugDescription)" ) + XCTAssertEqual(keyboardReads, expectedReads) } } @@ -132,6 +138,19 @@ extension RunnerTests { XCTAssertEqual(Self.textValueRead(elementType: .button, value: bullets), .unavailable) } + func testWarmupWaitsForPendingTextButNotAnUnreadableValue() { + let cases: [(TextValueRead, Bool)] = [ + (.text("a"), false), + (.text(""), true), + (.text("old"), true), + (.unavailable, true), + (.unreadable, false), + ] + for (read, pending) in cases { + XCTAssertEqual(read.warmupReadIsPending(expectedText: "a"), pending) + } + } + // The penalized route's clear reads through the same rule, so a pre-filled masked plain field // gets the unread passes a secure field gets and is then typed into unverified, instead of // ending the clear as a field that could not be read. diff --git a/docs/evidence/ios-e2e-engine-pr1236-2026-10-19.md b/docs/evidence/ios-e2e-engine-pr1236-2026-10-19.md new file mode 100644 index 0000000000..7cfbae8410 --- /dev/null +++ b/docs/evidence/ios-e2e-engine-pr1236-2026-10-19.md @@ -0,0 +1,114 @@ +# iOS engine PR 1236: performance and safety investigation + +Date: 2026-10-19. + +Agent-device is already competitive on this host. There is a credible route to faster observations and screenshots without weakening its interaction guarantees. This investigation does **not** establish a safe, general end-to-end win: the full runs had different failures, and the 39 mutually passing cases differed by only 2% in one run. No production behavior was changed. + +## What they are building + +[PR 1236](https://github.com/tester-army/e2e/pull/1236), examined at `71ee26c7c7b01eb8646d7acc456edf5ebb181205`, is the Swift proof of a language-independent engine protocol. Its parent [PR 1232](https://github.com/tester-army/e2e/pull/1232) defines the protocol; [PR 1234](https://github.com/tester-army/e2e/pull/1234) supplies the Java/Android proof. + +The existing route is e2e locators → mobile adapter → agent-device SDK/daemon → platform acquisition or XCTest runner. The new route is e2e `connectEngine` → framed JSON-RPC over loopback TCP → a Swift server inside one XCUITest test → app. `xcodebuild test-without-building` starts that server. Network work has its own queue; device commands execute serially on the main thread. Abort notifications can reach the queue while an operation runs. + +Swift builds its semantic tree from one `XCUIApplication.snapshot()`. Locator resolution still runs in TypeScript through `resolveLocators: true`; this is not a native selector-index optimization. Observed identities and frames support later gestures. Unique identifiers or type/label addresses use native element activation. The client handles restart/reset through `simctl`. Screenshots are masked from secure-field observations before and after capture. + +The engine is deliberately private and experimental. It has one simulator, a device fixture limited to back, and no video or general device-control parity. The 41 executed benchmark tests do not establish agent-device's complete interaction contract. + +Primary implementation references: [Swift device](https://github.com/tester-army/e2e/blob/71ee26c7c7b01eb8646d7acc456edf5ebb181205/packages/ios/server/Server/Device.swift), [server](https://github.com/tester-army/e2e/blob/71ee26c7c7b01eb8646d7acc456edf5ebb181205/packages/ios/server/Server/EngineServer.swift), [client/transport](https://github.com/tester-army/e2e/tree/71ee26c7c7b01eb8646d7acc456edf5ebb181205/packages/ios/src), [existing mobile adapter](https://github.com/tester-army/e2e/blob/71ee26c7c7b01eb8646d7acc456edf5ebb181205/packages/mobile/src/surface.ts). + +## Experiment + +- Agent-device source: `8f3eb3d2efde3654a560fddb39127d556e068352`, `0.21.29-dev`. The timed client imported this checkout's built SDK. The daemon reported `codeOrigin: checkout`. T3's installed `0.21.25` CLI was used only for installation/manual warmup. +- Hardware: Apple M4 Pro, 48 GiB RAM; Xcode 27.1 (`27A9275`); Node 26.11.1. +- Device: iPhone 17 Pro simulator, iOS 27.0, UDID `BC54AC11-CD86-4C10-8C4E-5101C773FFEB`. +- App: their `apps/mobile-benchmark`, `dev.e2e.benchmark`, bundled Release build. Tracked screens and original tests remained unchanged. +- Both engines used a 1,500 ms transition budget, one worker, and the same installed app. Swift retained its default idle waiting. Runs were sequential, with builds completed before measurement. +- The generated Expo 57 native host needed a `UIScene` delegate/manifest to launch with this SDK. This adaptation is included in the evidence bundle. [Apple documents the iOS 27 requirement](https://developer.apple.com/documentation/uikit/transitioning-to-the-uikit-scene-based-life-cycle). This is not an exact reproduction of the PR's iOS 26.5 environment. +- The local agent-device runner used its normal Debug scheme; their server build uses Release. This was a comparison of their normal build commands, not a matched compiler-optimization experiment. +- Microbenchmarks used five rounds, nearest-rank p50, and an additional untimed value check after every fill. All ten measured fills across both engines had correct values. Startup had one sample and is excluded from conclusions. + +The original microbenchmark gives Swift a default 500 ms transition budget while setting agent-device to 1,500 ms. This investigation corrected that mismatch. It also retained per-operation failures in JSON; the upstream harness can exit zero despite recorded operation failures. + +## Results + +Full original selection, one run per engine: + +| Measurement | Agent-device | Swift | +| --- | ---: | ---: | +| Total CLI duration | 525.43 s | 535.12 s | +| Passed / failed / skipped | 39 / 2 / 1 | 40 / 1 / 1 | +| Sum of attempt durations for the same 39 passing tests | 489.889 s | 500.006 s | +| Controls file | 179.92 s | 166.92 s | +| Lists file, including failed WebView case | 213.82 s | 259.15 s | +| Row-512 virtualized-list test | 116.16 s | 152.14 s | +| Login-form file | 46.57 s | 30.43 s | + +Agent-device full-run ID: `01a153a9-89c7-7eb5-84b9-c402ed0c144e`, exit 3. Swift full-run ID: `01a153b2-bc5b-730e-bb09-7095373a8255`, exit 1. The common-case subtotal excludes startup and includes only tests that passed in both runs. It is not a passing full-suite speed claim. + +Engine-call p50, milliseconds: + +| Operation | Agent-device | Swift | +| --- | ---: | ---: | +| Observe home | 92.6 | 21.6 | +| Observe form | 90.3 | 14.2 | +| Locate test ID | 84.5 | 13.3 | +| Tap field | 521.6 | 980.8 | +| Fill 19 characters | 3,139.8 | 1,988.3 | +| Type 10 characters | 1,221.3 | 442.7 | +| Screenshot | 703.8 | 118.8 | +| Observe with pixels | 682.8 | 115.8 | +| Swipe, successful calls only | 544.6 | 1,451.3 | +| Back until list visible | 895.4 | 1,213.3 | + +One agent-device swipe was refused as having no effect, leaving four successful samples. Repeated Enter-on-node succeeded once and failed four times on agent-device; Swift succeeded five times. Those rows are not equivalent successful workloads. The mobile adapter's Enter branch sends a keyboard command without refocusing the node; Swift taps the input first. + +Tap samples varied substantially on Swift (396–1,687 ms). The first fill into an empty field took 1,992 ms / 1,406 ms; subsequent samples replaced existing text. Repeated replacement therefore overstates clearing's importance in an ordinary fresh login. + +## Diagnosed failures + +**Shared WebView selector failure.** Both original runs failed `getByText('TA-BENCH-50')`. On iOS 27 the captured text is `Your coupon code is TA-BENCH-50.` and e2e defaults to exact matching. A separate test changed only that locator to the complete sentence, then performed the original fill, checkbox, apply, and success assertions. It passed on agent-device in 10.79 s and Swift in 11.59 s. The original suite was not edited or relabeled as passing. + +**Agent-device Bottom Tabs false occlusion.** After switching to Actions, the visibly unobstructed `complete-action-button` was refused with typed reason `target_covered`, `dispatched: no`. The reported cover was a transparent `_UIFloatingBarContainerView` toolbar at `(0, 0, 402, 791)`. + +This reproduced after a simulator restart. The regular bridge capture had 9 nodes and lost the content-branch ancestry; the raw capture had 41 nodes. A pure replay removed existing covered annotations, restored native hittability from the raw capture, and applied the same `annotateCoveredSnapshotNodes` function to each tree. The full tree left the target actionable; the regular projection marked it covered. An XCTest fallback capture also allowed the ref tap. A raw-ref tap reached the visible `Action completed` state, but raw mode deliberately skips normal occlusion annotation and is not a proposed fix. + +The owning problem is loss of structural evidence before occlusion classification. Investigate `packages/capture-kit/src/snapshot-state.ts:75`, the iOS regular-presentation pipeline, and `snapshot-occlusion.ts`. The existing sibling-sized chrome tests do not exercise this complete projection-to-ref-dispatch path. Preserve real modal/toolbar-child occlusion while fixing this ordering/provenance issue; do not exempt all toolbars. + +## Where safe performance work should go + +The agent-device suite issued 831 snapshot commands, spending 178.55 s inside them. It spent 77.24 s in 81 scroll commands and 83.39 s in 42 opens. Three screenshot commands, including failure captures, spent only 2.09 s. These command totals include native/recovery/stabilization work and exclude harness waits. + +1. **Consolidate fresh process facts.** After a fresh boot, a warm profiled observation took 103 ms median. Its two `pgrep` and three `ps` probes spent 66 ms median. The system-surface probe, target resolver, and bridge manager own these reads. A persistent native reader could provide fresh device-scoped surface and process-birth facts with fewer subprocesses. Keep unknown-versus-absent behavior, generation checks before publication, cancellation, and system-sheet fallback. Caching absence or dropping identity checks is not the optimization. This is the largest measured opportunity for this suite; no replacement implementation was benchmarked here. +2. **Keep the occlusion fix ahead of a win claim.** Preserve the structural evidence needed by the existing chrome rule across projection. Add a live-fixture regression through snapshot publication and ref admission. A faster false refusal is not a useful win. +3. **Optimize bound replacement/focus work.** Runner logs attributed about 539 ms to focus, 105 ms to initial resolution, 1,205 ms to clearing a nonempty field, and 307 ms to final verification, across the repeated 19-character fills. Investigate atomic, identity-bound clearing and fewer duplicate resolutions while retaining final read-back and replaced-input refusal. Do not globally raise synthesis speed: the existing controlled-input evidence records dropped characters at faster rates. +4. **Use the ready runner for pixels where its capture contract can be preserved.** In the warmed post-login profile, `simctl io ... screenshot` cost 417 ms median and the fresh display query 94 ms. Swift's native route shows that pixels need not cost 700 ms. Preserve active-panel identity, app/screen scope, rotation, density, and secure-field masking. A dynamic foldable panel choice must remain fresh. The images also differ in size: agent-device defaults to 402×874; Swift returns 1206×2622. This would help vision-driven agents far more than this mostly semantic suite. +5. **Reuse the existing fast PNG encoder when appropriate.** A standalone experiment compared general `PNG.sync.write` with existing `encodePngPixels`, using actual captures and checking decoded RGBA equality every time. At 402×874, p50 was 10.74 → 3.81 ms and output 29,881 → 18,929 bytes. At 1206×2622, it was 101.24 → 24.45 ms and 106,219 → 67,169 bytes. These are encode-only results, ten samples after two warmups. They are useful but cannot explain the screenshot gap. No encoder production path was changed. + +The Swift engine does not provide equivalent guarantees merely by passing its selected tests: fill deletes from an earlier observed value without agent-device's final verification; unique addressing is based on the observation; an action does not retire all observed references. Its optional idle-wait switch replaces private XCTest methods. Agent-device already has synthesized gesture/text paths, so adding synthesis is not a new architectural lever by itself. These are design differences to account for, not evidence that every Swift action is unsafe. + +## Reproduction and evidence + +Initial setup completed with `pnpm install --frozen-lockfile && pnpm build` in this checkout. The agent-device runner was built with `pnpm build:xcuitest`. In the external clone, the e2e, mobile, iOS, and GitHub packages were built, followed by `pnpm --filter @e2e-dev/ios build:server`. The app used `pnpm exec expo run:ios --configuration Release --device generic --no-bundler --output .build/ios`. + +The evidence bundle contains original/modified microbenchmark scripts, the current-SDK engine config, native-host adaptation, full JSON reports, terminal logs, raw samples, screenshot experiments, and the regular/raw occlusion captures. It excludes host connection files and daemon credentials. + +Representative commands from the external clone: + +```sh +# packages/ios; investigate.ts is included in the bundle +E2E_IOS_UDID= AGENT_DEVICE_STATE_DIR= \ + BENCH_ONLY=current node bench/investigate.ts --rounds 5 +E2E_IOS_UDID= BENCH_ONLY=direct node bench/investigate.ts --rounds 5 + +# apps/mobile-benchmark; custom config is included in the bundle +E2E_IOS_UDID= E2E_MOBILE_BENCHMARK_IOS_DEVICE= \ + AGENT_DEVICE_STATE_DIR= BENCH_ENGINE=current \ + node node_modules/e2e/dist/cli/bin.js run --config e2e.investigate.config.ts +# Repeat with BENCH_ENGINE=direct, after the first run has closed. +``` + +Adjust the bundled SDK import/derived-data paths to the checkout being tested. The upstream microbenchmark remains available as `packages/ios/bench/bench.ts`. The bundled config and native adaptation are patch inputs; Git's tracked-file `dirty: false` field in the upstream report does not describe them. + +All manually created sessions were closed. The isolated daemon stopped gracefully and its retained runner cleanup completed. The test simulator was shut down. No changes were pushed and no upstream messages were posted. + +Before a product speed claim, fix the regular-projection refusal, use a portable WebView locator, implement the selected optimization, and alternate repeated passing runs with matched build settings. Physical iOS devices, Android, and a global idle-wait change were not validated in this investigation. diff --git a/docs/evidence/ios-fill-clear-latency-2026-10-19.json b/docs/evidence/ios-fill-clear-latency-2026-10-19.json new file mode 100644 index 0000000000..d2b5e335b7 --- /dev/null +++ b/docs/evidence/ios-fill-clear-latency-2026-10-19.json @@ -0,0 +1,172 @@ +{ + "sourceCommit": "6a47e6177bf91be8ce1896b5dc789d1fe1ba9e4b", + "upstream": "https://github.com/tester-army/e2e/pull/1236", + "app": "dev.e2e.benchmark", + "device": "BC54AC11-CD86-4C10-8C4E-5101C773FFEB", + "os": "iOS 27.0", + "cliVersion": "0.21.25", + "baseline": { + "samples": [ + { + "kind": "secure-replace", + "ms": 3206.373416999999, + "ok": true + }, + { + "kind": "ordinary-replace", + "ms": 3818.350292000003, + "ok": true + }, + { + "kind": "secure-replace", + "ms": 3225.345207999999, + "ok": true + }, + { + "kind": "ordinary-replace", + "ms": 3774.5687499999985, + "ok": true + }, + { + "kind": "secure-replace", + "ms": 3218.5184169999993, + "ok": true + }, + { + "kind": "ordinary-replace", + "ms": 3638.0006250000006, + "ok": true + }, + { + "kind": "secure-replace", + "ms": 3219.4461249999877, + "ok": true + }, + { + "kind": "ordinary-replace", + "ms": 3995.6581250000017, + "ok": true + } + ], + "nativePasswordPhases": [ + { + "commandId": "runner-b82cbf9c-4242-4e9a-a3de-c167caec3ae3", + "phasesMs": { + "focus": 554.8, + "initial-resolve": 71.7, + "clear": 1098.6, + "type-first": 318.6, + "warmup": 66.7, + "type-remaining": 439.2, + "verify": 46.8, + "total": 2096.0 + } + }, + { + "commandId": "runner-1882b089-67a8-44f7-ae83-cbb6f3810599", + "phasesMs": { + "focus": 569.7, + "initial-resolve": 67.4, + "clear": 1092.0, + "type-first": 309.9, + "warmup": 63.7, + "type-remaining": 456.6, + "verify": 45.4, + "total": 2081.0 + } + }, + { + "commandId": "runner-192297dc-c264-4059-9118-a786f6837cfd", + "phasesMs": { + "focus": 544.9, + "initial-resolve": 64.1, + "clear": 1100.5, + "type-first": 307.6, + "warmup": 79.1, + "type-remaining": 472.1, + "verify": 45.9, + "total": 2119.7 + } + }, + { + "commandId": "runner-8b702125-ca4f-4b6f-a8e7-cf23438f6c14", + "phasesMs": { + "focus": 544.6, + "initial-resolve": 67.2, + "clear": 1096.8, + "type-first": 318.8, + "warmup": 68.9, + "type-remaining": 449.1, + "verify": 45.5, + "total": 2098.2 + } + } + ], + "nativeTotalExcludesFocus": true + }, + "rejectedCandidate": { + "samples": [ + { + "kind": "secure-replace", + "ms": 2553.417874999999, + "ok": false + } + ], + "nativePasswordPhases": [ + { + "commandId": "runner-10f9f1f5-956d-41a4-b180-cd776eac14d7", + "phasesMs": { + "focus": 562.7, + "initial-resolve": 62.3, + "clear": 709.9, + "type-first": 307.9, + "warmup": 72.6, + "type-remaining": 450.8, + "verify": 47.3, + "total": 1696.8 + } + } + ], + "passwordLoginVerified": false, + "failure": "The next snapshot routed to com.apple.SafariViewService and failed with invalid-viewport; the surface-selection cause is unproven. Timer is provisional, not a successful fill speed claim." + }, + "rejectedHarnessRuns": { + "baseline": "interactive-only snapshot omitted success-message", + "selectall": "retained baseline runner reused; not candidate evidence" + }, + "guardTest": { + "newTest": "testExperimentalClearMustRemoveTheWholeMaskedValueWhenSelectAllIsIgnored", + "otherTests": [ + "testFillFailsWhenAppReplacesInputBeforeTextIsDelivered", + "testFillFailsWhenAppRemovesInputBeforeTextIsDelivered" + ], + "candidate": { + "passedTests": 2, + "failedTests": 1, + "skippedTests": 0, + "testFailures": [ + { + "failureText": "XCTAssertFalse failed - fill reported success while the app retained stale text", + "targetName": "AgentDeviceRunnerUITests", + "testIdentifier": 1, + "testIdentifierString": "RunnerTests/testExperimentalClearMustRemoveTheWholeMaskedValueWhenSelectAllIsIgnored()", + "testIdentifierURL": "test://com.apple.xcode/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests/testExperimentalClearMustRemoveTheWholeMaskedValueWhenSelectAllIsIgnored", + "testName": "testExperimentalClearMustRemoveTheWholeMaskedValueWhenSelectAllIsIgnored()" + } + ] + }, + "control": { + "passedTests": 3, + "failedTests": 0, + "skippedTests": 0 + }, + "candidateExit": 65, + "controlExit": 0, + "controlRuntimeMatchesCommit": true + }, + "candidateAndProbePatch": "diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m\nindex 03e77eac81..4329ddb1aa 100644\n--- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m\n+++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m\n@@ -69,6 +69,13 @@ @interface AgentDeviceMaskedTextField : UITextField\n \n @implementation AgentDeviceMaskedTextField\n \n+- (void)selectAll:(id)sender {\n+ if ([NSProcessInfo.processInfo.arguments containsObject:@\"--agent-device-ignore-select-all\"]) {\n+ return;\n+ }\n+ [super selectAll:sender];\n+}\n+\n - (NSString *)accessibilityValue {\n NSUInteger length = self.text.length;\n if (length == 0) {\n@@ -468,7 +475,10 @@ static NSTimeInterval AgentDeviceTextEntryAcknowledgeWindow(void) {\n \n - (void)agentDeviceTextEntryDidChange:(UITextField *)textField {\n if (self.maskedDeliveryStatus != nil) {\n- self.maskedDeliveryStatus.text = [textField.text hasSuffix:@\"not-a-secret-123\"] ? @\"delivered\" : @\"pending\";\n+ BOOL exact = [NSProcessInfo.processInfo.arguments containsObject:@\"--agent-device-exact-clear-probe\"];\n+ BOOL delivered = exact ? [textField.text isEqualToString:@\"not-a-secret-123\"]\n+ : [textField.text hasSuffix:@\"not-a-secret-123\"];\n+ self.maskedDeliveryStatus.text = delivered ? @\"delivered\" : @\"pending\";\n }\n // A field whose app owns its value, the way a controlled React Native `TextInput` does. A burst\n // typed faster than the app renders loses the characters that arrived while a render was in\ndiff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift\nindex 02b936bda3..870622a333 100644\n--- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift\n+++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift\n@@ -254,6 +254,11 @@ extension RunnerTests {\n existing.isEmpty {\n return\n }\n+#if os(iOS)\n+ element.typeKey(\"a\", modifierFlags: .command)\n+ element.typeText(XCUIKeyboardKey.delete.rawValue)\n+ return\n+#endif\n #if !os(tvOS)\n moveCaretToEnd(app: app, element: element)\n #endif\ndiff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift\nindex ef7475e197..df0201e020 100644\n--- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift\n+++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift\n@@ -4,6 +4,35 @@ import XCTest\n // `textEntryMode: \"append\"`, and the bare submit key with no mode.\n extension RunnerTests {\n #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS)\n+ @MainActor\n+ func testExperimentalClearMustRemoveTheWholeMaskedValueWhenSelectAllIsIgnored() throws {\n+ app.launchArguments = [\n+ \"--agent-device-text-entry-regression\",\n+ \"--agent-device-text-entry-masked-value\",\n+ \"--agent-device-text-entry-prefilled\",\n+ \"--agent-device-ignore-select-all\",\n+ \"--agent-device-exact-clear-probe\",\n+ ]\n+ app.launch()\n+ defer { invalidateCachedTarget(reason: \"unit_test_cleanup\"); app.terminate() }\n+ XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout))\n+ let field = app.textFields[\"agent-device-hardware-keyboard-input\"]\n+ XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout))\n+ try tapHardwareKeyboardInput(commandId: \"tap-select-all-probe\")\n+ let response = executeTypeCommand(\n+ activeApp: app,\n+ command: try runnerCommandFixture(\n+ #\"{\"command\":\"type\",\"commandId\":\"fill-select-all-probe\",\"text\":\"not-a-secret-123\",\"textEntryMode\":\"replace\"}\"#\n+ )\n+ )\n+ let delivered = app.staticTexts[\"agent-device-masked-delivery\"]\n+ let exact = XCTNSPredicateExpectation(predicate: NSPredicate(format: \"label == %@\", \"delivered\"), object: delivered)\n+ let committed = XCTWaiter.wait(for: [exact], timeout: 2) == .completed\n+ XCTAssertFalse(response.ok && !committed, \"fill reported success while the app retained stale text\")\n+ XCTAssertTrue(response.ok, String(describing: response.error))\n+ XCTAssertTrue(committed, \"the app did not receive exactly the replacement\")\n+ }\n+\n @MainActor\n func testTypeWithoutResolvedInputReturnsTypedFailureBeforeDispatchingText() throws {\n let command = try runnerCommandFixture(\n", + "reproduction": { + "setup": "Create a disposable checkout at sourceCommit; run pnpm install --frozen-lockfile && pnpm build. Apply candidateAndProbePatch. Build iOS with AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 and a distinct AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH. For control, remove only the five-line Select All runtime change; keep the probe fixture/test patch unchanged. Build into another DerivedData directory. Run sequentially with no competing device processes.", + "testCommand": "xcodebuild test-without-building -xctestrun /Build/Products/AgentDeviceRunner_AgentDeviceRunnerUITests_iphonesimulator27.1-arm64.xctestrun -destination platform=iOS\\ Simulator,id=BC54AC11-CD86-4C10-8C4E-5101C773FFEB -parallel-testing-enabled NO -only-testing:AgentDeviceRunnerUITests/RunnerTests/testExperimentalClearMustRemoveTheWholeMaskedValueWhenSelectAllIsIgnored -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFillFailsWhenAppReplacesInputBeforeTextIsDelivered -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFillFailsWhenAppRemovesInputBeforeTextIsDelivered" + } +} diff --git a/docs/evidence/ios-text-daemon-latency-2026-10-19.json b/docs/evidence/ios-text-daemon-latency-2026-10-19.json new file mode 100644 index 0000000000..e32bddd8e0 --- /dev/null +++ b/docs/evidence/ios-text-daemon-latency-2026-10-19.json @@ -0,0 +1,149 @@ +{ + "sourceBase": "8f3eb3d2efde3654a560fddb39127d556e068352", + "app": "dev.e2e.benchmark", + "device": "iPhone 17 Pro, iOS 27.0", + "upstream": "https://github.com/tester-army/e2e/pull/1236", + "nativeSourceFingerprint": "a2479bea7162d3ba08450f6e88c9c53b7bbb1328f61d0dfb23b3ec9d414d2034", + "median": "Mean of the central pair for even sample counts.", + "samples": { + "capture-sequential-warm": { + "warmupsExcluded": 2, + "milliseconds": [ + 460.4799589999993, 91.5342499999997, 92.33041700000012, 91.33758399999988, + 88.06654200000048, 87.57887500000015, 94.8838340000002, 90.15012500000012, + 87.82183299999997, 88.7772500000001, 91.15179199999966, 90.45970800000032, + 90.84708300000057, 94.55108399999881, 91.87408299999879, 89.83549999999923, + 90.30787500000042, 87.95908400000008, 88.26158399999986, 95.80924999999843, + 95.54483299999993, 94.87554100000125, 95.10733299999993, 94.50158300000112, + 86.40845799999988, 91.72645899999952, 90.29808299999968, 92.88558299999931, + 94.52820799999972, 93.74183299999822, 98.24012500000026, 95.44324999999844 + ], + "nodes": 34, + "fallbacks": 0 + }, + "capture-parallel-warm": { + "warmupsExcluded": 2, + "milliseconds": [ + 438.44091599999956, 74.26379199999974, 76.3238339999998, 77.18262500000037, + 79.73174999999992, 76.21458299999995, 76.02820800000063, 75.75500000000011, + 76.8057919999992, 74.16058299999986, 74.03320900000017, 75.02683400000024, + 77.01058300000022, 77.63758399999915, 73.62208400000054, 76.24966700000004, + 75.76012499999888, 73.12162500000159, 74.81524999999965, 78.37166699999943, + 76.28525000000081, 76.05424999999923, 75.36220800000046, 75.21420900000157, + 74.61666699999842, 75.49387500000012, 74.04991699999846, 75.63166699999965, + 76.83087499999965, 75.75520799999867, 75.91354200000023, 75.4565419999999 + ], + "nodes": 34, + "fallbacks": 0 + }, + "password-baseline-verified": { + "milliseconds": [3143.0931249999994, 3636.8446669999976, 3344.322709, 3289.290125000007], + "successfulLogins": 4 + }, + "password-warmup-baseline-verified": { + "milliseconds": [462.7, 446.4, 463.0, 400.8] + }, + "password-production": { + "milliseconds": [ + 2818.7710420000003, 3070.121041999999, 3076.7858750000014, 3016.898540999995 + ], + "successfulLogins": 4 + }, + "password-warmup-production": { + "milliseconds": [73.5, 68.8, 79.0, 56.1] + }, + "text-baseline-clean": { + "fill-empty": { + "milliseconds": [ + 2169.352541, 2040.0450840000012, 2038.1687090000014, 1952.8112079999992, + 2011.012582999996, 2047.5521249999947, 2056.345792, 2019.859083000003 + ], + "failures": 0 + }, + "fill-replace": { + "milliseconds": [ + 3228.7047920000005, 3250.1123750000006, 3235.6019159999996, 3262.082665999995, + 3236.0727499999994, 3210.3902499999967, 3234.2858750000087, 3236.3368340000015 + ], + "failures": 0 + }, + "type-append": { + "milliseconds": [ + 1202.507458, 1198.6578750000008, 1226.3141660000001, 1204.447540999994, + 1200.0966249999983, 1206.3476669999945, 1208.7802500000107, 1206.8783750000002 + ], + "failures": 0 + } + }, + "text-production-warm": { + "fill-empty": { + "milliseconds": [1989.972917, 2011.2156250000007, 2068.6344169999975, 2045.7098750000005], + "failures": 0 + }, + "fill-replace": { + "milliseconds": [ + 3220.879208999999, 3186.019292000001, 3203.5044169999965, 3317.675166000001 + ], + "failures": 0 + }, + "type-append": { + "milliseconds": [ + 1192.5328329999993, 1186.7882499999978, 1198.5200000000004, 1176.7431670000005 + ], + "failures": 0 + } + }, + "text-no-warmup": { + "fill-empty": { + "milliseconds": [ + 1745.0530839999992, 1660.5627910000003, 1672.8450420000008, 1726.6735829999961, + 1711.0416250000053, 1692.1331659999996, 1723.7318749999977, 1662.9657920000027 + ], + "failures": 0 + }, + "fill-replace": { + "milliseconds": [ + 2949.5647499999995, 2901.131708000001, 2949.8871670000008, 2932.541999999994, + 2880.678791999999, 3004.605042000003, 2955.0863329999993, 2897.6092089999875 + ], + "failures": 0 + }, + "type-append": { + "milliseconds": [ + 1025.0007910000004, 1022.1937499999985, 1019.7220410000009, 1026.1973330000037, + 1029.3868750000038, 1021.9305420000019, 1055.699625000001, 1024.4197499999864 + ], + "failures": 0 + } + } + }, + "nativeVerification": { + "passed": 17, + "failed": 0, + "skipped": 0, + "command": "#!/bin/sh\nset -eu\nxcodebuild test-without-building \\\n -xctestrun /private/tmp/agent-device-speed-runner-production-ios/Build/Products/AgentDeviceRunner_AgentDeviceRunnerUITests_iphonesimulator27.1-arm64.xctestrun \\\n -destination 'platform=iOS Simulator,id=BC54AC11-CD86-4C10-8C4E-5101C773FFEB' \\\n -parallel-testing-enabled NO \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedFirstResponderTypeAdmitsOnlyTheBareSubmitKeyAfterAHiddenKeyboardTap \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testWarmupWaitsForPendingTextButNotAnUnreadableValue \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextValueReadOwnsTheUnreadableRule \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTypePlanPeelsAWarmupCharacterAndChargesItsReadBack \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFillFailsWhenAppRemovesInputBeforeTextIsDelivered \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFillFailsWhenAppReplacesInputBeforeTextIsDelivered \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFillSucceedsWhenAppReplacesInputAfterLastCharacter \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFillSucceedsWhenAppRemovesInputAfterLastCharacter \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFillWithAppWriteBackFailsWithoutRetyping \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFillIntoMaskedTextFieldIsUnverifiedRatherThanAMismatch \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareSubmitKeyUsesSynthesizedFirstResponderAfterHiddenKeyboardTap \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareSubmitKeyRefusesWhenPrivateSynthesisIsUnavailable \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFillThatClearsOnSubmitIsUnverifiedAndSubmitsOnce \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFillIntoTemplateCurrencyFieldReportsUnconfirmedWithoutRetyping \\\n -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFillVerifiesAnUnidentifiedFieldThatMovesWhileTyping \\\n -resultBundlePath /private/tmp/agent-device-speed-production-tests.xcresult\n" + }, + "detection": { + "sequentialPresence": { + "passed": 13, + "failed": 1 + }, + "parallelPresence": { + "passed": 14, + "failed": 0 + }, + "policyControl": { + "passed": 1, + "failed": 0 + }, + "policyMutant": { + "passed": 0, + "failed": 1 + }, + "burstCandidate": { + "passed": 0, + "failed": 2, + "successorValue": "23456" + } + } +} diff --git a/docs/evidence/ios-text-daemon-latency-2026-10-19.md b/docs/evidence/ios-text-daemon-latency-2026-10-19.md new file mode 100644 index 0000000000..6c03fcfa3a --- /dev/null +++ b/docs/evidence/ios-text-daemon-latency-2026-10-19.md @@ -0,0 +1,102 @@ +# iOS text entry and daemon latency — 2026-10-19 + +Three changes reduce measured overhead while retaining the first-character split: overlap the two fresh system-surface scans, defer an irrelevant keyboard query, and stop polling a password value that accessibility has already classified as unreadable. Removing the split itself is unsafe: a native regression sent `23456` into the next screen's input and reported success. + +This follows the [PR #1236 investigation](ios-e2e-engine-pr1236-2026-10-19.md). It uses the same `dev.e2e.benchmark` app from [tester-army/e2e#1236](https://github.com/tester-army/e2e/pull/1236), including the previously documented native-host adaptation. No benchmark screen or credential validator changed. + +## Measured results + +Times are conventional medians, including the mean of the two central values for even sample counts. Device runs were sequential, without competing builds. Every successful ordinary fill/append had an independent accessibility read-back outside its timer. Every accepted password sample reached the app's successful-login state outside its timer. + +| Measurement | Before | Implemented | Evidence | +| --- | ---: | ---: | --- | +| Warm full home snapshot | 91.5 ms | 75.8 ms | 30 timed captures each; same 34 nodes; no fallback | +| Fresh system-host presence probe | 44.8 ms | 24.6 ms | 30 timed probes each; every result `absent` | +| Replace a nonempty password, 12 characters | 3,316.8 ms | 3,043.5 ms | Four each; all eight logins succeeded | +| Password first-character observation phase | 454.5 ms | 71.2 ms | Same four measured replacements per version | +| Fill an empty ordinary field, 19 characters | 2,039.1 ms | 2,028.5 ms | Eight before, four after | +| Replace an ordinary field, 19 characters | 3,235.8 ms | 3,212.2 ms | Eight before, four after | +| Append ten characters | 1,205.4 ms | 1,189.7 ms | Eight before, four after | + +The ordinary-text differences are small relative to run variation. They do not establish an isolated latency benefit for the deferred keyboard query. The password's native observation phase supplies stronger attribution: it removes about 383 ms of polling; the complete command improves by about 273 ms. Secure fields still return unverified text evidence. + +The two capture/probe warmups are excluded from their 30-sample groups. The capture harness primes the runner, relaunches the app, and measures fresh captures in the new process generation. The complete tree size is checked on every sample. Both capture groups use the same external runner artifact. + +## What changed + +`packages/platform-apple/src/system-surface-presence.ts` starts both `pgrep` scans together and waits for their results. It retains host priority, device-scoped environment reads, conservative `unknown` handling, and the positive-only memo. Every absent capture still scans afresh. No process-generation check was removed. + +`RunnerTests+SynthesizedTextEntry.swift` makes `softwareKeyboardVisible` an autoclosure. The owning predicate evaluates it only for a bare Return key after a witnessed tap. Ordinary fills and appends no longer perform a native keyboard query whose result cannot affect their route. The visible/hidden keyboard decisions remain covered by the existing policy and live tests. + +`RunnerTests+TextEntry.swift` gives the existing typed `TextValueRead` a warmup decision. A readable mismatch and `.unavailable` remain pending. An exact match is ready. `.unreadable` cannot confirm a literal character, so it no longer waits for that impossible observation. `RunnerTests+TextTyping.swift` takes the existing short poll pause for this case, then continues through the same target resolution and final verification. The split, 12-character synthesis pace, delivery budget, binding, and repair policy remain intact. + +## Why the split stays + +An isolated candidate changed only the main typing plan to `peelsWarmupCharacter: false`. + +| Ordinary form operation | Original | One burst | Samples per version | +| --- | ---: | ---: | ---: | +| Empty fill | 2,039.1 ms | 1,701.6 ms | 8 | +| Replacement fill | 3,235.8 ms | 2,941.1 ms | 8 | +| Append | 1,205.4 ms | 1,024.7 ms | 8 | + +All form samples read back correctly. The stronger native test boundary rejected the candidate: four scenarios passed and two failed. Removing or replacing the input after its first character returned success instead of `TEXT_INPUT_NOT_FOCUSED`. In the replacement scenario, the successor input held `23456` instead of remaining empty. + +The split therefore contributes delivery evidence as well as readiness. Between the first post and the rest, the runner can refuse a different input. A single XCTest typing call can continue delivering events after navigation. Final verification cannot distinguish that from an input that correctly auto-submitted after receiving its last character. The final implementation passes both navigation regressions and retains successful auto-submit behavior. + +The two navigation failures were reproduced again from the final native source plus the one-line candidate patch. The separate test-detection experiment changed `.unreadable` back to pending: its control passed and its mutant failed. The control's native source fingerprint matched the production build. All experiments ran in a disposable checkout. + +## Raw daemon cost + +Fifty alternating requests to the same local daemon measured: + +| Path | Median | +| --- | ---: | +| Authenticated loopback TCP `session_list` request and response | 0.346 ms | +| Public SDK `sessions.list()` | 6.865 ms | + +The raw path includes transport, token authorization, command routing, and response serialization for this cheap read-only command. It is a lower bound; it does not measure device admission or native work for other commands. + +The SDK path performs registration/owner inspection, current code compatibility checks, and reachability work before sending. Instrumentation recorded one `/bin/ps` invocation per SDK call, including its untimed first call. The raw path bypasses that client lifecycle work. These measurements do not justify removing ownership checks or changing cancellation and ambiguous-delivery behavior to save a fraction of a millisecond in the transport. + +The larger common cost remains fresh host process facts around captures. Parallel scans save about 16 ms in the complete warmed capture here. The target resolver and bridge manager still make their process-birth checks. Consolidating those facts in their owning native/protocol boundary remains a larger candidate; absence caching and dropping PID-generation checks are not part of this patch. + +## Kept failures and comparison limits + +One final-code text run hit `foreground-owner-unverified` after a cold startup AX timeout. The source circuit disabled that process generation and used XCTest. All 24 text checks passed, but command medians rose to 2,293/3,477/1,412 ms for empty fill/replacement/append. Those samples remain a separate fallback cohort. A warmed new generation used the bridge and supplied the four-sample final text comparison above. + +An unchanged-baseline capture run also hit a cold-start foreground-owner failure and used XCTest: 156.7 ms median and a 37-node tree. It is retained separately from the two 34-node bridge-backed capture groups. Neither cold cohort is averaged into the warm speed claim. These observations establish the startup fallback on both versions; they do not establish its root cause or frequency. + +The first text baseline was interrupted by this investigation's cleanup and contains canceled samples. Two password-harness attempts did not handle the system `Save Password?` sheet and failed to reach their assertion. Those attempts are excluded, with their failures preserved. The corrected harness dismisses `Not Now` and validates the app result. It never saves the test credentials. + +This follow-up does not rerun the full upstream 42-case suite or test a physical device. It does not establish a general speed lead over the Swift engine. The earlier cross-engine report remains the comparison for the full suite and screenshots. Field clearing still costs about 1.1–1.2 seconds in these samples; it is the larger remaining text-entry cost. + +## Follow-up: can replacement reach two seconds? + +The next experiment replaced the caret edge-tap and delete burst with public, element-scoped XCTest calls: `typeKey("a", modifierFlags: .command)` followed by one delete. The warmup split, binding, and final read-back stayed unchanged. The candidate ran only in a disposable checkout. No further runtime optimization was accepted. + +This follow-up used T3's installed `0.21.25` CLI, with explicit runner artifact paths, on the same upstream app and simulator. It therefore has a separate baseline from the SDK measurements above. Four baseline password replacements had a median of 3,219.0 ms, and all four reached successful login. Their median native clearing phase was 1,097.7 ms. A confirmed fresh candidate runner returned one replacement in 2,553.4 ms, with a 709.9 ms clearing phase. The next snapshot was routed to `com.apple.SafariViewService` and failed with an `invalid-viewport` timeout, so login was not verified. The evidence does not establish why that surface was selected. That timer is provisional and does not establish a successful-fill speed improvement. + +The native safety comparison rejected the shortcut independently of that capture failure. A masked, prefilled fixture ignored Select All and exposed a separate app-owned exact-match indicator. The candidate returned success while that indicator showed that stale text remained. The candidate passed the existing two early-navigation regressions but failed the new whole-value replacement case: **2 passed, 1 failed**. The same fixture, test selection, and environment with the current clearing implementation passed **3 of 3**. This control's runtime source matched commit `6a47e6177bf91be8ce1896b5dc789d1fe1ba9e4b` byte-for-byte. + +The [samples, phase timings, complete candidate/probe patch, and reproduction command](ios-fill-clear-latency-2026-10-19.json) preserve this result. An initial harness attempt omitted noninteractive success text; another candidate attempt reused the retained baseline runner. Both are excluded. The valid candidate was confirmed by its fresh xcodebuild artifact path and logged Command-A dispatches. The simulator was restarted between artifacts and shut down after the experiments. + +There is no demonstrated safe two-second replacement here. Select All does not prove that a masked input was cleared, and final literal verification cannot supply that proof. The next design work is an identity-bound clear operation with observable completion, plus consolidating duplicate native reads within each observation. Keep the existing opaque-field clearing path until that evidence exists. Removing the first-character guard or globally increasing typing speed does not address these failures. + +## Validation and reproduction + +Base: `8f3eb3d2efde3654a560fddb39127d556e068352`, with the preserved implementation patch. Device: iPhone 17 Pro, iOS 27.0, `BC54AC11-CD86-4C10-8C4E-5101C773FFEB`. Host: M4 Pro, Xcode 27.1, Node 26.11.1. The live runs used the worktree patch; the PR records the published validation commit. + +- The capture/routing suites passed: 43 tests. +- The final simulator runner selection passed: 17 tests, zero failures or skips. It includes hidden-keyboard Return/type, early input disappearance/replacement, last-character auto-submit, app-owned write-back, masked delivery, formatting, moving inputs, and the new policies. +- iOS and macOS native builds passed. +- `pnpm check:quick` passed lint and TypeScript checks. +- Full-repository formatting, the package build, layering, XCTest selection, and packaged Swift checks are recorded in the evidence bundle. +- Presence regression proof: 13 passed/1 failed on the sequential source; 14 passed with overlapping scans. +- Deferred keyboard regression proof: the enhanced policy test failed on the eager source with seven unexpected reads; it passed in the final selection. +- Unreadable-value policy proof: one control test passed; changing unreadable to pending made it fail. +- The final-source burst candidate failed both early-navigation tests; the final implementation passed them. + +The [committed samples and native verification command](ios-text-daemon-latency-2026-10-19.json) support the measurements above. The complete local reproducibility bundle is `.tmp/text-daemon-speed-evidence.zip`. Its source directory is `.tmp/text-daemon-speed/`. It contains raw samples, phase logs, exact test commands, candidate patches, selected startup diagnostics, test summaries, and build/check logs. It excludes daemon registration files and credentials. Configure `SPEED_XCTESTRUN_FILE` and `SPEED_RUNNER_PATH` to select an explicitly built artifact. Configure `SPEED_CLIENT_ENTRY` for the capture comparison. Run each benchmark sequentially with its own state directory. The report's upstream app setup is in the preceding investigation. + +Task-owned sessions and daemons are closed after verification. The disposable checkout is removed. The test simulator is shut down. diff --git a/packages/platform-apple/src/system-surface-presence.test.ts b/packages/platform-apple/src/system-surface-presence.test.ts index 75a6c1e5cd..371e8fe64e 100644 --- a/packages/platform-apple/src/system-surface-presence.test.ts +++ b/packages/platform-apple/src/system-surface-presence.test.ts @@ -105,6 +105,25 @@ test('no host process at all is absent without reading any environment', async ( expect(mockRunCmd.mock.calls.every(([command]) => command === 'pgrep')).toBe(true); }); +test('fresh host scans overlap and absence waits for both answers', async () => { + const pending = new Map void>(); + mockRunCmd.mockImplementation(async (_command, args = []) => { + const reply = await new Promise((resolve) => pending.set(args[1]!, resolve)); + return { ...reply, stderr: '' }; + }); + let settled = false; + const result = createSystemSurfacePresenceProbe()(sim).then((presence) => { + settled = true; + return presence; + }); + expect([...pending.keys()]).toEqual([SAFARI_EXECUTABLE, PASSBOOK_EXECUTABLE]); + pending.get(SAFARI_EXECUTABLE)!(NOT_RUNNING); + await Promise.resolve(); + expect(settled).toBe(false); + pending.get(PASSBOOK_EXECUTABLE)!({ exitCode: 2, stdout: '' }); + await expect(result).resolves.toBe('unknown'); +}); + test('a non-simulator is absent without probing', async () => { const probe = createSystemSurfacePresenceProbe(); await expect(probe({ ...sim, kind: 'device' } as DeviceInfo)).resolves.toBe('absent'); diff --git a/packages/platform-apple/src/system-surface-presence.ts b/packages/platform-apple/src/system-surface-presence.ts index 5a4a6ea51a..c2719ce8ac 100644 --- a/packages/platform-apple/src/system-surface-presence.ts +++ b/packages/platform-apple/src/system-surface-presence.ts @@ -61,9 +61,14 @@ async function probeSystemSurfacePresence( device: DeviceInfo, signal: AbortSignal | undefined, ): Promise { + const hostPids = await Promise.all( + IOS_SYSTEM_SURFACE_HOSTS.map(async (host) => ({ + host, + pids: await hostProcessIds(host.processExecutable, signal), + })), + ); let sawUnknown = false; - for (const host of IOS_SYSTEM_SURFACE_HOSTS) { - const pids = await hostProcessIds(host.processExecutable, signal); + for (const { host, pids } of hostPids) { if (pids === 'unknown') { sawUnknown = true; continue;