From a9938ccd45ba1a93ae80ad4eb4c6da4b2f7a03ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 21 Aug 2026 17:31:58 +0200 Subject: [PATCH 1/3] fix(ios): isolate snapshot acquisition timing --- .../RunnerTests+SnapshotCapturePlan.swift | 229 +++++++++--------- .../RunnerTests+SnapshotTiming.swift | 185 ++++++++++++++ packages/kernel/src/snapshot.ts | 7 + .../__tests__/verdict.test.ts | 12 + src/snapshot-quality/verdict.ts | 14 ++ 5 files changed, 337 insertions(+), 110 deletions(-) create mode 100644 apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index ee2ca1f787..4f7f22a7b2 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -25,6 +25,8 @@ struct SnapshotQuality: Codable { let collapsedLeafIndexes: [Int]? /// Coverage of the bounded custom-action pass, when the capture asked for one. let customActions: SnapshotCustomActionCoverage? + /// Response-level timing for the accepted backend attempt, never repeated per node. + var timing: SnapshotCaptureTiming? = nil } /// How much of the merged-element set the custom-action pass actually read. An @@ -76,6 +78,9 @@ struct SnapshotBackendCapture { /// Broad presentation used only by the quality classifier when a scope narrows publication. /// A legitimate missing scope is an empty healthy projection, not backend failure evidence. var qualityPayload: DataPayload? = nil + /// Set by the capture plan after measuring acquisition and presentation separately. Direct + /// presentation fixtures do not claim a plan timing. + var timing: SnapshotCaptureTiming? = nil } extension RunnerTests { @@ -327,42 +332,19 @@ extension RunnerTests { } continue } - let capture: SnapshotBackendCapture - let backendStartedAt = Date() - do { - guard - let result = try captureWithBackend( - kind, - app: app, - options: options, - deadline: deadline, - treeCaptureSliceBudgetOverride: effective.treeCaptureSliceBudgetOverride - ) - else { - recordSlowXCTestSnapshotBackendIfNeeded( - kind, - startedAt: backendStartedAt, - penaltySuppressed: suppressXCTestPenalty - ) - continue - } - capture = result - recordSlowXCTestSnapshotBackendIfNeeded( - kind, - startedAt: backendStartedAt, - penaltySuppressed: suppressXCTestPenalty - ) - } catch let failure as SnapshotCaptureFailure { - recordXCTestSnapshotBackendFailureIfNeeded( - kind, - failure: failure, - penaltySuppressed: suppressXCTestPenalty - ) - recordSlowXCTestSnapshotBackendIfNeeded( - kind, - startedAt: backendStartedAt, - penaltySuppressed: suppressXCTestPenalty - ) + let attempt = try captureWithBackend( + kind, + app: app, + options: options, + deadline: deadline, + treeCaptureSliceBudgetOverride: effective.treeCaptureSliceBudgetOverride + ) + recordXCTestSnapshotBackendAttemptIfNeeded( + kind, + attempt: attempt, + penaltySuppressed: suppressXCTestPenalty + ) + if case let .failed(failure, phase: _) = attempt.outcome { if Self.isAxSnapshotFailure(failure) { axFailure = failure } if firstFailure == nil { firstFailure = (failure.message, Self.isAxSnapshotFailure(failure) ? "ax-rejected" : "capture-failed") @@ -374,6 +356,7 @@ extension RunnerTests { ) continue } + guard case let .captured(capture) = attempt.outcome else { continue } if let sparseReason = Self.sparsePayloadReason(capture.qualityPayload ?? capture.payload) { if firstFailure == nil { firstFailure = sparseReason } @@ -429,92 +412,96 @@ extension RunnerTests { return fallbackPayload } - /// Marks XCTest-backed snapshot tiers as penalized when one attempt ground past the slow-capture - /// threshold — even a successful one: the next capture of this screen must not re-grind. - private func recordSlowXCTestSnapshotBackendIfNeeded( - _ kind: SnapshotBackendKind, - startedAt: Date, - penaltySuppressed: Bool - ) { - guard !penaltySuppressed else { return } - guard kind.usesXCTestAccessibilityChannel else { return } - let elapsed = Date().timeIntervalSince(startedAt) - guard elapsed > snapshotXCTestSlowCaptureThreshold else { return } - penalizeSnapshotXCTestChannel( - bundleId: currentBundleId, - reason: "slow_\(kind.rawValue)_capture_\(Int(elapsed * 1000))ms" - ) - } - - private func recordXCTestSnapshotBackendFailureIfNeeded( - _ kind: SnapshotBackendKind, - failure: SnapshotCaptureFailure, - penaltySuppressed: Bool - ) { - guard !penaltySuppressed else { return } - guard kind.usesXCTestAccessibilityChannel, failure.code == Self.xCTestSnapshotTimeoutCode else { return } - penalizeSnapshotXCTestChannel( - bundleId: currentBundleId, - reason: "\(kind.rawValue)_backend_timeout" - ) - } - private func captureWithBackend( _ kind: SnapshotBackendKind, app: XCUIApplication, options: PresentationOptions, deadline: Date, treeCaptureSliceBudgetOverride: TimeInterval? - ) throws -> SnapshotBackendCapture? { + ) throws -> SnapshotBackendAttempt { let hint = SnapshotPresentation.captureHint(for: options) + var timer = SnapshotPhaseTimer() let acquisition: SnapshotAcquisition? - switch kind { - case .recursiveTree: - guard - let context = try makeSnapshotTraversalContext( - app: app, - hint: hint, - captureDeadline: deadline, - treeCaptureSliceBudgetOverride: treeCaptureSliceBudgetOverride - ) - else { - return nil - } - acquisition = try runMainThreadWork( - timeout: min(treeCaptureSliceBudget, max(0.5, deadline.timeIntervalSinceNow)), - timeoutError: snapshotMainThreadTimeoutError("processing tree snapshot") - ) { - hint.isRaw - ? try self.rawTreeSnapshotAcquisition(context: context, hint: hint) - : self.recursiveTreeSnapshotAcquisition(context: context, hint: hint) - } - case .querySweep: - acquisition = try runMainThreadWork( - timeout: min(Self.flatInteractiveFallbackBudget, max(0.1, deadline.timeIntervalSinceNow)), - timeoutError: snapshotMainThreadTimeoutError("running query-sweep snapshot") - ) { - self.querySweepSnapshotAcquisition( - app: app, - hint: hint, - planDeadline: deadline - ) + do { + acquisition = try timer.measure(.acquisition) { + switch kind { + case .recursiveTree: + guard + let context = try self.makeSnapshotTraversalContext( + app: app, + hint: hint, + captureDeadline: deadline, + treeCaptureSliceBudgetOverride: treeCaptureSliceBudgetOverride + ) + else { + return nil + } + return try self.runMainThreadWork( + timeout: min(self.treeCaptureSliceBudget, max(0.5, deadline.timeIntervalSinceNow)), + timeoutError: self.snapshotMainThreadTimeoutError("processing tree snapshot") + ) { + hint.isRaw + ? try self.rawTreeSnapshotAcquisition(context: context, hint: hint) + : self.recursiveTreeSnapshotAcquisition(context: context, hint: hint) + } + case .querySweep: + return try self.runMainThreadWork( + timeout: min(Self.flatInteractiveFallbackBudget, max(0.1, deadline.timeIntervalSinceNow)), + timeoutError: self.snapshotMainThreadTimeoutError("running query-sweep snapshot") + ) { + self.querySweepSnapshotAcquisition( + app: app, + hint: hint, + planDeadline: deadline + ) + } + case .privateAX: + return self.privateAXSnapshotAcquisition( + app: app, + hint: hint, + deadline: deadline + ) + } } - case .privateAX: - acquisition = privateAXSnapshotAcquisition( - app: app, - hint: hint, - deadline: deadline + } catch let failure as SnapshotCaptureFailure { + return SnapshotBackendAttempt( + outcome: .failed(failure, phase: .acquisition), + timing: timer.timing ) } - guard let acquisition else { return nil } - guard let capture = SnapshotPresentation.present(acquisition, options: options) else { - throw Self.snapshotProjectionMismatchFailure( - kind, - requested: hint.projection, - acquired: acquisition.hint.projection + + guard let acquisition else { + return SnapshotBackendAttempt( + outcome: .noCapture, + timing: timer.timing ) } - return capture + + let presented: SnapshotBackendCapture + do { + presented = try timer.measure(.presentation) { + guard let capture = SnapshotPresentation.present(acquisition, options: options) else { + throw Self.snapshotProjectionMismatchFailure( + kind, + requested: hint.projection, + acquired: acquisition.hint.projection + ) + } + return capture + } + } catch let failure as SnapshotCaptureFailure { + return SnapshotBackendAttempt( + outcome: .failed(failure, phase: .presentation), + timing: timer.timing + ) + } + + var capture = presented + capture.timing = timer.timing + return SnapshotBackendAttempt( + outcome: .captured(capture), + timing: timer.timing + ) } /// A backend that answers a request with the other projection loses its tier and says why, so @@ -632,7 +619,8 @@ extension RunnerTests { reasonCode: reason?.code, effectiveDepth: capture.effectiveDepth, collapsedLeafIndexes: Self.collapsedLeafIndexes(payload.nodes ?? []), - customActions: capture.customActions + customActions: capture.customActions, + timing: capture.timing ) return DataPayload( // Legacy human text for older daemons that read message instead of snapshotQuality. @@ -847,6 +835,27 @@ extension RunnerTests { XCTAssertEqual(Self.xcTestChannelStateFirstFailure(.boundedXCTestProbe)?.code, "budget") } + func testSnapshotQualityCarriesPhaseTimingAtResponseLevel() { + let timing = SnapshotCaptureTiming(acquisitionMs: 12, presentationMs: 34) + let capture = SnapshotBackendCapture( + payload: DataPayload( + nodes: [planTestNode(index: 0, type: "Application", label: "App")], + truncated: false + ), + effectiveDepth: nil + ) + + let payload = stampedSnapshotPayload( + capture, + backend: .recursiveTree, + state: "healthy", + reason: nil + ) + + XCTAssertEqual(payload.snapshotQuality?.timing, timing) + XCTAssertEqual(payload.nodes?.count, 1) + } + /// The raw plan is derived from what each backend can actually serve, not from a second /// hand-maintained list. Non-vacuity: flipping `querySweep.supportsRawProjection` to true adds it /// to the plan and fails the first two assertions — which is exactly the shape of #1797 D4, a diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift new file mode 100644 index 0000000000..442aa6d1b9 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift @@ -0,0 +1,185 @@ +import Foundation +import XCTest + +struct SnapshotCaptureTiming: Codable, Equatable { + let acquisitionMs: Double + let presentationMs: Double + + init(acquisitionMs: Double, presentationMs: Double) { + self.acquisitionMs = max(0, acquisitionMs) + self.presentationMs = max(0, presentationMs) + } + + init(acquisition: TimeInterval, presentation: TimeInterval) { + self.init( + acquisitionMs: max(0, acquisition) * 1_000, + presentationMs: max(0, presentation) * 1_000 + ) + } +} + +enum SnapshotCapturePhase: Equatable { + case acquisition + case presentation +} + +struct SnapshotPhaseTimer { + private let now: () -> Date + private var acquisitionSeconds: TimeInterval = 0 + private var presentationSeconds: TimeInterval = 0 + + init(now: @escaping () -> Date = { Date() }) { + self.now = now + } + + mutating func measure( + _ phase: SnapshotCapturePhase, + _ operation: () throws -> T + ) rethrows -> T { + let startedAt = now() + defer { + let elapsed = max(0, now().timeIntervalSince(startedAt)) + switch phase { + case .acquisition: + acquisitionSeconds += elapsed + case .presentation: + presentationSeconds += elapsed + } + } + return try operation() + } + + var timing: SnapshotCaptureTiming { + SnapshotCaptureTiming( + acquisition: acquisitionSeconds, + presentation: presentationSeconds + ) + } +} + +extension RunnerTests { + struct SnapshotBackendAttempt { + enum Outcome { + case noCapture + case captured(SnapshotBackendCapture) + case failed(SnapshotCaptureFailure, phase: SnapshotCapturePhase) + } + + /// The failure phase is part of the result, so penalty policy cannot infer it from duration + /// or error text. + let outcome: Outcome + let timing: SnapshotCaptureTiming + } + + /// The penalty breaker observes only acquisition facts. Presentation is a separate phase and + /// cannot arm the breaker, even when it is slower than the acquisition that produced the tree. + private static func snapshotXCTestPenaltyReason( + kind: SnapshotBackendKind, + attempt: SnapshotBackendAttempt, + slowThresholdMs: Double + ) -> String? { + guard kind.usesXCTestAccessibilityChannel else { return nil } + if case let .failed(failure, phase: .acquisition) = attempt.outcome, + failure.code == Self.xCTestSnapshotTimeoutCode + { + return "\(kind.rawValue)_backend_timeout" + } + guard attempt.timing.acquisitionMs > slowThresholdMs else { return nil } + return "slow_\(kind.rawValue)_capture_\(Int(attempt.timing.acquisitionMs))ms" + } + + func recordXCTestSnapshotBackendAttemptIfNeeded( + _ kind: SnapshotBackendKind, + attempt: SnapshotBackendAttempt, + penaltySuppressed: Bool + ) { + guard !penaltySuppressed else { return } + guard + let reason = Self.snapshotXCTestPenaltyReason( + kind: kind, + attempt: attempt, + slowThresholdMs: snapshotXCTestSlowCaptureThreshold * 1_000 + ) + else { return } + penalizeSnapshotXCTestChannel( + bundleId: currentBundleId, + reason: reason + ) + } +} + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +extension RunnerTests { + func testXCTestPenaltyDecisionSeparatesAcquisitionAndPresentation() { + let slowPresentation = SnapshotBackendAttempt( + outcome: .noCapture, + timing: SnapshotCaptureTiming(acquisitionMs: 100, presentationMs: 4_000) + ) + XCTAssertNil( + Self.snapshotXCTestPenaltyReason( + kind: .recursiveTree, + attempt: slowPresentation, + slowThresholdMs: 3_000 + ) + ) + + let slowAcquisition = SnapshotBackendAttempt( + outcome: .noCapture, + timing: SnapshotCaptureTiming(acquisitionMs: 3_001, presentationMs: 100) + ) + XCTAssertEqual( + Self.snapshotXCTestPenaltyReason( + kind: .recursiveTree, + attempt: slowAcquisition, + slowThresholdMs: 3_000 + ), + "slow_tree_capture_3001ms" + ) + + let timeout = SnapshotCaptureFailure( + code: Self.xCTestSnapshotTimeoutCode, + message: "test timeout", + hint: "test" + ) + let acquisitionFailure = SnapshotBackendAttempt( + outcome: .failed(timeout, phase: .acquisition), + timing: SnapshotCaptureTiming(acquisitionMs: 100, presentationMs: 100) + ) + XCTAssertEqual( + Self.snapshotXCTestPenaltyReason( + kind: .recursiveTree, + attempt: acquisitionFailure, + slowThresholdMs: 3_000 + ), + "tree_backend_timeout" + ) + + let presentationFailure = SnapshotBackendAttempt( + outcome: .failed(timeout, phase: .presentation), + timing: SnapshotCaptureTiming(acquisitionMs: 100, presentationMs: 100) + ) + XCTAssertNil( + Self.snapshotXCTestPenaltyReason( + kind: .recursiveTree, + attempt: presentationFailure, + slowThresholdMs: 3_000 + ) + ) + } + + func testSnapshotPhaseTimerReportsAcquisitionAndPresentationSeparately() { + var now = Date(timeIntervalSinceReferenceDate: 100) + var timer = SnapshotPhaseTimer(now: { now }) + + _ = timer.measure(.acquisition) { + now = now.addingTimeInterval(2) + } + _ = timer.measure(.presentation) { + now = now.addingTimeInterval(5) + } + + XCTAssertEqual(timer.timing.acquisitionMs, 2_000, accuracy: 0.001) + XCTAssertEqual(timer.timing.presentationMs, 5_000, accuracy: 0.001) + } +} +#endif diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index 5d11ca7c7f..5c5c83603a 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -18,6 +18,11 @@ export type SnapshotCaptureBackend = 'tree' | 'queries' | 'private-ax'; /** Internal backends that evidence probes may select explicitly. */ export type SnapshotPreferredBackend = 'tree' | 'private-ax'; +export type SnapshotQualityTiming = { + acquisitionMs: number; + presentationMs: number; +}; + export type SnapshotQualityVerdict = { state: 'healthy' | 'recovered' | 'sparse'; backend: SnapshotCaptureBackend; @@ -45,6 +50,8 @@ export type SnapshotQualityVerdict = { * pass has to be disclosed rather than left to look complete. */ customActions?: { read: number; candidates: number; truncated: number; blocked: boolean }; + /** Response-level phase timing for the backend named by `backend`. */ + timing?: SnapshotQualityTiming; }; export type Rect = { diff --git a/src/snapshot-quality/__tests__/verdict.test.ts b/src/snapshot-quality/__tests__/verdict.test.ts index 0aefe8954f..3f54cf9ec9 100644 --- a/src/snapshot-quality/__tests__/verdict.test.ts +++ b/src/snapshot-quality/__tests__/verdict.test.ts @@ -16,6 +16,7 @@ test('readSnapshotQualityVerdict accepts a well-formed verdict', () => { effectiveDepth: 56, collapsedLeafIndexes: [3], customActions: { read: 12, candidates: 19, truncated: 1, blocked: false }, + timing: { acquisitionMs: 12.5, presentationMs: 34.75 }, }); assert.deepEqual(verdict, { state: 'recovered', @@ -25,9 +26,20 @@ test('readSnapshotQualityVerdict accepts a well-formed verdict', () => { effectiveDepth: 56, collapsedLeafIndexes: [3], customActions: { read: 12, candidates: 19, truncated: 1, blocked: false }, + timing: { acquisitionMs: 12.5, presentationMs: 34.75 }, }); }); +test('readSnapshotQualityVerdict drops incomplete phase timing without dropping the verdict', () => { + const verdict = readSnapshotQualityVerdict({ + state: 'healthy', + backend: 'tree', + timing: { acquisitionMs: 12.5 }, + }); + assert.equal(verdict?.state, 'healthy'); + assert.equal(verdict?.timing, undefined); +}); + test('readSnapshotQualityVerdict rejects unknown state or backend as verdict-absent', () => { // A malformed object must not be treated as an authoritative verdict — it has to fall through // so legacy node-shape detectors still run instead of being silently suppressed. diff --git a/src/snapshot-quality/verdict.ts b/src/snapshot-quality/verdict.ts index c7de220dde..c38d1b25c1 100644 --- a/src/snapshot-quality/verdict.ts +++ b/src/snapshot-quality/verdict.ts @@ -37,6 +37,7 @@ export function readSnapshotQualityVerdict(value: unknown): SnapshotQualityVerdi ) { return undefined; } + const timing = readSnapshotQualityTiming(raw.timing); return { state: raw.state as SnapshotQualityVerdict['state'], backend: raw.backend as SnapshotQualityVerdict['backend'], @@ -55,6 +56,19 @@ export function readSnapshotQualityVerdict(value: unknown): SnapshotQualityVerdi collapsedLeafIndexes: Array.isArray(raw.collapsedLeafIndexes) ? raw.collapsedLeafIndexes.filter((entry): entry is number => typeof entry === 'number') : undefined, + ...(timing ? { timing } : {}), + }; +} + +function readSnapshotQualityTiming(value: unknown): SnapshotQualityVerdict['timing'] | undefined { + if (!value || typeof value !== 'object') return undefined; + const raw = value as Record; + if (typeof raw.acquisitionMs !== 'number' || typeof raw.presentationMs !== 'number') { + return undefined; + } + return { + acquisitionMs: raw.acquisitionMs, + presentationMs: raw.presentationMs, }; } From 5d1b437108a8fe38c94619d66d01b183f668f975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 21 Aug 2026 18:52:53 +0200 Subject: [PATCH 2/3] fix(ios): pin snapshot phase timing fixture --- .../RunnerTests+SnapshotCapturePlan.swift | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index 4f7f22a7b2..05251e12fa 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -842,7 +842,8 @@ extension RunnerTests { nodes: [planTestNode(index: 0, type: "Application", label: "App")], truncated: false ), - effectiveDepth: nil + effectiveDepth: nil, + timing: timing ) let payload = stampedSnapshotPayload( @@ -856,6 +857,34 @@ extension RunnerTests { XCTAssertEqual(payload.nodes?.count, 1) } + func testDirectPresentationDoesNotClaimPlanTiming() { + let options = PresentationOptions( + interactiveOnly: false, + depth: nil, + scope: nil, + raw: true + ) + let capture = SnapshotPresentation.presentRaw( + SnapshotAcquisition( + hint: SnapshotPresentation.captureHint(for: options), + nodes: [], + truncated: false, + effectiveDepth: nil, + viewport: .infinite + ), + options: options + ) + + let payload = stampedSnapshotPayload( + capture, + backend: .recursiveTree, + state: "healthy", + reason: nil + ) + + XCTAssertNil(payload.snapshotQuality?.timing) + } + /// The raw plan is derived from what each backend can actually serve, not from a second /// hand-maintained list. Non-vacuity: flipping `querySweep.supportsRawProjection` to true adds it /// to the plan and fails the first two assertions — which is exactly the shape of #1797 D4, a From b0122094552a9661af97b56a58dc9df37e9f57ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 21 Aug 2026 20:36:35 +0200 Subject: [PATCH 3/3] fix(ios): avoid starving synthesized text commits --- .../RunnerTests+SynthesizedTextEntry.swift | 4 +++- .../AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift index ef53ca8f04..b37bf14661 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift @@ -275,7 +275,9 @@ extension RunnerTests { treatingPlaceholderAsEmpty: true ) }, - waitForNextObservation: { sleepFor(TextEntryTiming.pollInterval) } + // XCUI resolution shares the automation channel with the in-flight synthesized event. + // Sparse reads let the target consume that event instead of continuously interrupting it. + waitForNextObservation: { sleepFor(TextEntryTiming.synthesizedCommitPollInterval) } ) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift index c1f1600c67..d4a8493438 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift @@ -46,6 +46,7 @@ extension RunnerTests { static let warmupValueTimeout: TimeInterval = 0.4 static let verificationStabilityWindow: TimeInterval = 0.2 static let synthesizedCommitTimeout: TimeInterval = 3.0 + static let synthesizedCommitPollInterval: TimeInterval = 0.2 } struct TextEntryResult {