Skip to content

Commit 2260149

Browse files
committed
fix: refresh Apple runner state after app relaunch
1 parent acc8f30 commit 2260149

7 files changed

Lines changed: 138 additions & 6 deletions

File tree

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ NS_ASSUME_NONNULL_BEGIN
99
maxDepth:(NSInteger)maxDepth
1010
maxNodes:(NSInteger)maxNodes;
1111

12+
+ (NSInteger)processIdentifierForApplication:(XCUIApplication *)application;
13+
1214
@end
1315

1416
NS_ASSUME_NONNULL_END

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ + (NSInteger)integerFrom:(id)target selectorName:(NSString *)selectorName
164164
return send(target, selector);
165165
}
166166

167+
+ (NSInteger)processIdentifierForApplication:(XCUIApplication *)application
168+
{
169+
return [self integerFrom:application selectorName:@"processID"];
170+
}
171+
167172
+ (id)accessibilityApplicationForApplication:(XCUIApplication *)application axClient:(id)axClient
168173
{
169174
NSInteger targetProcessID = [self integerFrom:application selectorName:@"processID"];

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,8 @@ extension RunnerTests {
945945
if let bundleId = requestedBundleId {
946946
if currentBundleId != bundleId || currentApp == nil {
947947
_ = activateTarget(bundleId: bundleId, reason: "bundle_changed")
948+
} else {
949+
refreshCachedTargetIfProcessChanged(bundleId: bundleId)
948950
}
949951
} else {
950952
// Do not reuse stale bundle targets when the caller does not explicitly request one.

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ extension RunnerTests {
8585
}
8686
currentApp = app
8787
currentBundleId = nil
88+
currentAppProcessIdentifier = nil
89+
snapshotXCTestPenaltyWarmupExemptionPending = false
8890
}
8991

9092
func invalidateCachedTarget(reason: String) {
@@ -93,6 +95,42 @@ extension RunnerTests {
9395
}
9496
currentApp = nil
9597
currentBundleId = nil
98+
currentAppProcessIdentifier = nil
99+
snapshotXCTestPenaltyWarmupExemptionPending = false
100+
}
101+
102+
func refreshCachedTargetIfProcessChanged(bundleId: String) {
103+
guard currentBundleId == bundleId, currentApp != nil else { return }
104+
let candidate = XCUIApplication(bundleIdentifier: bundleId)
105+
let observedProcessIdentifier = Self.processIdentifier(of: candidate)
106+
guard Self.shouldRefreshCachedTarget(
107+
cachedProcessIdentifier: currentAppProcessIdentifier,
108+
observedProcessIdentifier: observedProcessIdentifier
109+
) else { return }
110+
NSLog(
111+
"AGENT_DEVICE_RUNNER_TARGET_CACHE_REFRESH bundle=%@ previousPid=%d currentPid=%d",
112+
bundleId,
113+
currentAppProcessIdentifier ?? 0,
114+
observedProcessIdentifier ?? 0
115+
)
116+
currentApp = candidate
117+
currentAppProcessIdentifier = observedProcessIdentifier
118+
clearSnapshotXCTestChannelPenalty(reason: "target_process_changed")
119+
snapshotXCTestPenaltyWarmupExemptionPending = true
120+
needsFirstInteractionDelay = true
121+
}
122+
123+
static func processIdentifier(of target: XCUIApplication) -> Int? {
124+
let value = RunnerAXSnapshotBridge.processIdentifier(for: target)
125+
return value > 0 ? value : nil
126+
}
127+
128+
static func shouldRefreshCachedTarget(
129+
cachedProcessIdentifier: Int?,
130+
observedProcessIdentifier: Int?
131+
) -> Bool {
132+
guard let cachedProcessIdentifier, let observedProcessIdentifier else { return false }
133+
return cachedProcessIdentifier != observedProcessIdentifier
96134
}
97135

98136
func targetNeedsActivation(_ target: XCUIApplication) -> Bool {
@@ -141,6 +179,8 @@ extension RunnerTests {
141179
target.activate()
142180
currentApp = target
143181
currentBundleId = bundleId
182+
currentAppProcessIdentifier = Self.processIdentifier(of: target)
183+
snapshotXCTestPenaltyWarmupExemptionPending = false
144184
needsFirstInteractionDelay = true
145185
return target
146186
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import XCTest
2+
3+
extension RunnerTests {
4+
func testCachedTargetRefreshRequiresChangedPositiveProcessIdentity() {
5+
XCTAssertFalse(
6+
Self.shouldRefreshCachedTarget(
7+
cachedProcessIdentifier: nil,
8+
observedProcessIdentifier: 42
9+
)
10+
)
11+
XCTAssertFalse(
12+
Self.shouldRefreshCachedTarget(
13+
cachedProcessIdentifier: 42,
14+
observedProcessIdentifier: 42
15+
)
16+
)
17+
XCTAssertTrue(
18+
Self.shouldRefreshCachedTarget(
19+
cachedProcessIdentifier: 41,
20+
observedProcessIdentifier: 42
21+
)
22+
)
23+
}
24+
25+
func testSnapshotPenaltyWarmupExemptionIsConsumedOnce() {
26+
snapshotXCTestPenaltyWarmupExemptionPending = true
27+
28+
XCTAssertTrue(consumeSnapshotXCTestPenaltyWarmupExemption())
29+
XCTAssertFalse(consumeSnapshotXCTestPenaltyWarmupExemption())
30+
}
31+
32+
func testSnapshotPenaltyCanBeClearedAcrossTargetProcessReplacement() {
33+
penalizeSnapshotXCTestChannel(bundleId: "com.example.app", reason: "test")
34+
XCTAssertTrue(isSnapshotXCTestChannelPenalized(bundleId: "com.example.app"))
35+
36+
clearSnapshotXCTestChannelPenalty(reason: "target_process_changed")
37+
38+
XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: "com.example.app"))
39+
}
40+
}

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,17 @@ extension RunnerTests {
118118
)
119119
}
120120

121+
func clearSnapshotXCTestChannelPenalty(reason: String) {
122+
snapshotXCTestChannelPenaltyLock.lock()
123+
let hadActivePenalty = Date() < snapshotXCTestChannelPenaltyUntil
124+
snapshotXCTestChannelPenaltyBundleId = nil
125+
snapshotXCTestChannelPenaltyUntil = Date.distantPast
126+
snapshotXCTestChannelPenaltyLock.unlock()
127+
if hadActivePenalty {
128+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PENALTY_CLEARED reason=%@", reason)
129+
}
130+
}
131+
121132
func isSnapshotXCTestChannelPenalized(bundleId: String?) -> Bool {
122133
snapshotXCTestChannelPenaltyLock.lock()
123134
defer { snapshotXCTestChannelPenaltyLock.unlock() }
@@ -127,6 +138,12 @@ extension RunnerTests {
127138
return penalized == bundleId
128139
}
129140

141+
func consumeSnapshotXCTestPenaltyWarmupExemption() -> Bool {
142+
let pending = snapshotXCTestPenaltyWarmupExemptionPending
143+
snapshotXCTestPenaltyWarmupExemptionPending = false
144+
return pending
145+
}
146+
130147
/// Pure plan-reorder rule: a penalized XCTest accessibility channel uses independent backends
131148
/// when the platform has one, otherwise it keeps XCTest work on a short probe. The raw
132149
/// diagnostic plan keeps tree-first errors, and unknown plans are left untouched.
@@ -174,6 +191,7 @@ extension RunnerTests {
174191
var firstFailure: (reason: String, code: String)?
175192
var axFailure: SnapshotCaptureFailure?
176193
let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget)
194+
let suppressXCTestPenalty = consumeSnapshotXCTestPenaltyWarmupExemption()
177195

178196
// Reorder is iOS-only because hostile screens can make XCTest tree/query work grind while
179197
// the app remains visually responsive. Simulators can avoid that channel through private AX;
@@ -237,14 +255,30 @@ extension RunnerTests {
237255
treeCaptureSliceBudgetOverride: effective.treeCaptureSliceBudgetOverride
238256
)
239257
else {
240-
recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
258+
recordSlowXCTestSnapshotBackendIfNeeded(
259+
kind,
260+
startedAt: backendStartedAt,
261+
penaltySuppressed: suppressXCTestPenalty
262+
)
241263
continue
242264
}
243265
capture = result
244-
recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
266+
recordSlowXCTestSnapshotBackendIfNeeded(
267+
kind,
268+
startedAt: backendStartedAt,
269+
penaltySuppressed: suppressXCTestPenalty
270+
)
245271
} catch let failure as SnapshotCaptureFailure {
246-
recordXCTestSnapshotBackendFailureIfNeeded(kind, failure: failure)
247-
recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
272+
recordXCTestSnapshotBackendFailureIfNeeded(
273+
kind,
274+
failure: failure,
275+
penaltySuppressed: suppressXCTestPenalty
276+
)
277+
recordSlowXCTestSnapshotBackendIfNeeded(
278+
kind,
279+
startedAt: backendStartedAt,
280+
penaltySuppressed: suppressXCTestPenalty
281+
)
248282
if Self.isAxSnapshotFailure(failure) { axFailure = failure }
249283
if firstFailure == nil {
250284
firstFailure = (failure.message, Self.isAxSnapshotFailure(failure) ? "ax-rejected" : "capture-failed")
@@ -313,7 +347,12 @@ extension RunnerTests {
313347

314348
/// Marks XCTest-backed snapshot tiers as penalized when one attempt ground past the slow-capture
315349
/// threshold — even a successful one: the next capture of this screen must not re-grind.
316-
private func recordSlowXCTestSnapshotBackendIfNeeded(_ kind: SnapshotBackendKind, startedAt: Date) {
350+
private func recordSlowXCTestSnapshotBackendIfNeeded(
351+
_ kind: SnapshotBackendKind,
352+
startedAt: Date,
353+
penaltySuppressed: Bool
354+
) {
355+
guard !penaltySuppressed else { return }
317356
guard kind.usesXCTestAccessibilityChannel else { return }
318357
let elapsed = Date().timeIntervalSince(startedAt)
319358
guard elapsed > snapshotXCTestSlowCaptureThreshold else { return }
@@ -325,8 +364,10 @@ extension RunnerTests {
325364

326365
private func recordXCTestSnapshotBackendFailureIfNeeded(
327366
_ kind: SnapshotBackendKind,
328-
failure: SnapshotCaptureFailure
367+
failure: SnapshotCaptureFailure,
368+
penaltySuppressed: Bool
329369
) {
370+
guard !penaltySuppressed else { return }
330371
guard kind.usesXCTestAccessibilityChannel, failure.code == Self.xCTestSnapshotTimeoutCode else { return }
331372
penalizeSnapshotXCTestChannel(
332373
bundleId: currentBundleId,

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ final class RunnerTests: XCTestCase {
3838
lazy var springboard = XCUIApplication(bundleIdentifier: Self.springboardBundleId)
3939
var currentApp: XCUIApplication?
4040
var currentBundleId: String?
41+
var currentAppProcessIdentifier: Int?
4142
let maxRequestBytes = 2 * 1024 * 1024
4243
let mainThreadExecutionTimeout: TimeInterval = 30
4344
let appExistenceTimeout: TimeInterval = 30
@@ -76,6 +77,7 @@ final class RunnerTests: XCTestCase {
7677
var snapshotXCTestChannelPenaltyBundleId: String?
7778
var snapshotXCTestChannelPenaltyUntil = Date.distantPast
7879
let snapshotXCTestChannelPenaltyDuration: TimeInterval = 120
80+
var snapshotXCTestPenaltyWarmupExemptionPending = false
7981
// Bluesky-class screens can grind ~4-8s before an XCTest-backed snapshot tier fails; anything
8082
// past this threshold marks the screen hostile so the next capture uses non-XCTest recovery.
8183
let snapshotXCTestSlowCaptureThreshold: TimeInterval = 3

0 commit comments

Comments
 (0)