Skip to content

Commit 0bca57c

Browse files
committed
test(ios): resolve the alert before the deadline the replacement fixtures assert on
Nine iOS CI failures of the alert replacement fixtures answered ALERT_DEADLINE_EXCEEDED with "No alert button was activated": the command deadline also covered resolution, about two dozen accessibility reads before any tap, and a contended hosted simulator spent it all there (job 106268275388 sampled a 0.823 s read, sized a 39.5 s deadline, then spent 39 s resolving). The latency-sampled deadline from #2708 cannot predict a host that slows down mid-command. The fixtures now resolve the alert first under a 60 s allowance and hand it to the command through `alertResolutionOverrideForTesting`, so `timeoutMs` bounds only activation and verification, which is what they prove: the replacement is never activated. That window gets a fixed 30 s. A confirmed answer returns as soon as verification sees the replacement; only the unprovable-replacement fixture pays it in full. The latency sampler and its decomposition test are removed. The busy-app fixture asserted wall-clock success under a fixed 6 s deadline. It now records whether the button was answered while its animation was still in flight, and the test asserts that instead. Reverting the seam to a plain `activateElement` fails it with "Answered after the app went idle".
1 parent 2d6717d commit 0bca57c

3 files changed

Lines changed: 50 additions & 98 deletions

File tree

‎.github/workflows/ios.yml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,6 @@ jobs:
189189
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertAcceptDoesNotActivateAReplacementWithASharedButton \
190190
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDismissDoesNotActivateAReplacementWithTheSameTitle \
191191
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt \
192-
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertReplacementCommandDeadlineScalesWithMeasuredLatency \
193192
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDeadlineBeforeActivationLeavesTheOriginalUntouched \
194193
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertHittableProbeCompletingAfterDeadlineLeavesTheOriginalUntouched \
195194
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertActivationIgnoresAnAppThatNeverSettlesBeforeTheDeadline \

‎apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ int main(int argc, const char *argv[]) {
6262

6363
@interface AgentDeviceRunnerViewController : UIViewController
6464
@property(nonatomic, strong) UILabel *alertActionStatus;
65+
@property(nonatomic, strong) UILabel *alertActivationBusyAnswer;
6566
@property(nonatomic, assign) NSUInteger firstAlertActions;
6667
@property(nonatomic, assign) NSUInteger replacementAlertActions;
6768
@property(nonatomic, assign) BOOL alertFixtureStarted;
@@ -183,6 +184,10 @@ - (void)presentAlertFixtureReplacement:(BOOL)replacement {
183184
? UIAlertActionStyleCancel : UIAlertActionStyleDefault;
184185
[alert addAction:[UIAlertAction actionWithTitle:buttonTitle style:style handler:^(UIAlertAction *action) {
185186
(void)action;
187+
if (!replacement) {
188+
self.alertActivationBusyAnswer.text = self.alertActivationBusyBackstop != nil
189+
? @"Answered while busy" : @"Answered after the app went idle";
190+
}
186191
[self stopAlertActivationBusy];
187192
[self stopAlertBanner];
188193
if (replacement) {
@@ -253,6 +258,19 @@ - (void)viewDidLoad {
253258
[self updateAlertActionStatus];
254259
}
255260

261+
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-activation-busy"]) {
262+
UILabel *busyAnswer = [[UILabel alloc] init];
263+
busyAnswer.text = @"Unanswered";
264+
busyAnswer.accessibilityIdentifier = @"agent-device-alert-busy-answer";
265+
busyAnswer.translatesAutoresizingMaskIntoConstraints = NO;
266+
[self.view addSubview:busyAnswer];
267+
[NSLayoutConstraint activateConstraints:@[
268+
[busyAnswer.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
269+
[busyAnswer.topAnchor constraintEqualToAnchor:label.bottomAnchor constant:24],
270+
]];
271+
self.alertActivationBusyAnswer = busyAnswer;
272+
}
273+
256274
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-regression"]) {
257275
UITextField *textField = [[UITextField alloc] init];
258276
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertObservationTests.swift‎

Lines changed: 32 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,6 @@ extension RunnerTests {
1818
)
1919
}
2020

21-
func testAlertReplacementCommandDeadlineScalesWithMeasuredLatency() {
22-
// A quiet host still asks for the production default: 24 reads at 10 ms is a quarter of a second.
23-
XCTAssertEqual(RunnerTests.alertReplacementCommandTimeoutMs(measuredRoundTrip: 0.001), 10_000)
24-
XCTAssertEqual(RunnerTests.alertReplacementCommandTimeoutMs(measuredRoundTrip: 0.01), 10_000)
25-
// The linear region is where a contended host lands: one second per read buys the 24 reads
26-
// twice over, which is what the red nightly nights needed and 10 s did not cover.
27-
XCTAssertEqual(RunnerTests.alertReplacementCommandTimeoutMs(measuredRoundTrip: 1), 48_000)
28-
// Past the cap the fixture gives up rather than dominate the lane, whatever the host costs.
29-
XCTAssertEqual(RunnerTests.alertReplacementCommandTimeoutMs(measuredRoundTrip: 2.5), 60_000)
30-
XCTAssertEqual(RunnerTests.alertReplacementCommandTimeoutMs(measuredRoundTrip: 300), 60_000)
31-
XCTAssertEqual(
32-
RunnerTests.alertResolutionReadsBeforeActivation,
33-
24,
34-
"the decomposition below is the traced command path; re-trace it before changing a count"
35-
)
36-
}
37-
3821
func testAlertDeadlineBeforeActivationLeavesTheOriginalUntouched() throws {
3922
app.launchArguments = ["--agent-device-alert-replacement-regression"]
4023
app.launch()
@@ -43,7 +26,7 @@ extension RunnerTests {
4326
app.terminate()
4427
}
4528
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
46-
let alert = try XCTUnwrap(resolveAlert(app: app, deadline: Date().addingTimeInterval(10)))
29+
let alert = try resolveAlertBeforeTheCommand()
4730
let response = handleAlert(alert, action: "accept", deadline: .distantPast)
4831
XCTAssertFalse(response.ok)
4932
XCTAssertEqual(response.error?.code, "ALERT_DEADLINE_EXCEEDED")
@@ -60,7 +43,7 @@ extension RunnerTests {
6043
app.terminate()
6144
}
6245
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
63-
let alert = try XCTUnwrap(resolveAlert(app: app, deadline: Date().addingTimeInterval(10)))
46+
let alert = try resolveAlertBeforeTheCommand()
6447
alertButtonHittabilityProbeOverrideForTesting = { probeDeadline in
6548
while Date() < probeDeadline {
6649
Thread.sleep(forTimeInterval: min(0.02, max(0, probeDeadline.timeIntervalSinceNow)))
@@ -85,28 +68,16 @@ extension RunnerTests {
8568
app.terminate()
8669
}
8770
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
88-
let alert = try XCTUnwrap(resolveAlert(app: app, deadline: Date().addingTimeInterval(10)))
71+
let alert = try resolveAlertBeforeTheCommand()
8972

90-
let deadline = Date().addingTimeInterval(6)
91-
let startedAt = Date()
92-
let response = handleAlert(alert, action: "accept", deadline: deadline)
93-
let elapsed = Date().timeIntervalSince(startedAt)
73+
let response = handleAlert(alert, action: "accept", deadline: Date().addingTimeInterval(RunnerTests.alertActivationDeadline))
9474

95-
// The fixture keeps an animation in flight, which is what XCTest waits out before it synthesises
96-
// an event, so this comes back early only because activation refused to wait for an app that has
97-
// no intention of settling (#2546).
98-
XCTAssertLessThan(elapsed, 9, "activation waited \(elapsed)s for a busy app to idle")
75+
// The fixture keeps an animation in flight until a button is answered or its 20 s backstop stops
76+
// it, and an in-flight animation is what XCTest waits out before it synthesises an event. An
77+
// answer that arrives while the app is still busy is one activation did not wait to idle (#2546).
9978
XCTAssertTrue(response.ok, String(describing: response.error))
100-
let recordedActions = app.staticTexts["agent-device-alert-actions"].label
101-
if response.ok {
102-
XCTAssertEqual(recordedActions, "First actions: 1; replacement actions: 0")
103-
} else {
104-
XCTAssertEqual(
105-
recordedActions,
106-
"First actions: 0; replacement actions: 0",
107-
"a caller told about an expired deadline must not have a button activated behind it"
108-
)
109-
}
79+
XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 1; replacement actions: 0")
80+
XCTAssertEqual(app.staticTexts["agent-device-alert-busy-answer"].label, "Answered while busy")
11081
}
11182

11283
func testAlertActivationDoesNotWaitOutANotificationBanner() throws {
@@ -127,9 +98,9 @@ extension RunnerTests {
12798
}
12899
acceptNotificationAuthorizationUntilAlertAppears()
129100
XCTAssertTrue(banner.waitForExistence(timeout: appExistenceTimeout), "the fixture keeps a banner up")
130-
let alert = try XCTUnwrap(resolveAlert(app: app, deadline: Date().addingTimeInterval(30)))
101+
let alert = try resolveAlertBeforeTheCommand()
131102

132-
let response = handleAlert(alert, action: "accept", deadline: Date().addingTimeInterval(30))
103+
let response = handleAlert(alert, action: "accept", deadline: Date().addingTimeInterval(RunnerTests.alertActivationDeadline))
133104

134105
XCTAssertEqual(consultedInterruptions, [], "alert activation waited on XCTest's interruption handling")
135106
XCTAssertTrue(response.ok, String(describing: response.error))
@@ -152,81 +123,45 @@ extension RunnerTests {
152123
XCTAssertTrue(fixtureAlert.exists, "the banner fixture needs notification authorization before it presents its alert")
153124
}
154125

155-
/// The accessibility round trips `resolveAlert` spends before a button is chosen, decomposed from
156-
/// the implementation rather than counted off one trace: the blocking-modal probe scans
157-
/// SpringBoard's alert and sheet lists and re-reads the candidate it settles on; the app's own
158-
/// alert list is resolved and then read for existence and frame; `actionableElements` issues one
159-
/// fetch per member of `actionableTypes`; and every candidate then pays `exists`, `isHittable`,
160-
/// `elementType`, `frame`, `label` and `isEnabled` of its own.
161-
static let alertResolutionModalProbeReads = 3
162-
static let alertResolutionAlertRootReads = 3
163-
static let alertResolutionActionableTypeReads = 6
164-
static let alertResolutionButtonCandidates = 2
165-
static let alertResolutionCandidateReads = 6
166-
167-
static let alertResolutionReadsBeforeActivation =
168-
alertResolutionModalProbeReads + alertResolutionAlertRootReads + alertResolutionActionableTypeReads
169-
+ alertResolutionButtonCandidates * alertResolutionCandidateReads
170-
171-
/// The reads that follow resolution — the hittability wait and the first verification observation
172-
/// run on the same channel — plus the allowance for a host that gets slower mid-command.
173-
private static let alertCommandSafetyFactor: TimeInterval = 2
174-
175-
/// No higher than the slowest test this lane already runs (61 s on a green nightly), so a badly
176-
/// starved host cannot make these fixtures the lane's worst contributor. The cap covers the worst
177-
/// night traced so far, which needed 40.4 s of deadline.
178-
private static let alertCommandTimeoutCap: TimeInterval = 60
179-
180-
/// The alert command's deadline for the replacement fixtures.
181-
///
182-
/// `timeoutMs` bounds the whole command, so it is only meaningful in units of what one
183-
/// accessibility read costs this host right now: `RunnerTests+Alert.swift` starts the deadline at
184-
/// dispatch and the resolution above spends all of it reading before anything is activated. The
185-
/// hosted lane runs a read at about 10 ms on a healthy night and about 1.7 s on a red one, which
186-
/// is why the fixed 10 s these fixtures asked for passed on green nights and expired mid-resolution
187-
/// on red ones (runs 35, 36 and 37 needed 31.2 s, 30.6 s and 40.4 s). Deriving the budget from a
188-
/// measured read is `docs/agents/testing.md`'s preferred answer for a timeout that only fails on a
189-
/// contended host; production still defaults to `defaultAlertCommandTimeout` for real callers.
190-
static func alertReplacementCommandTimeoutMs(measuredRoundTrip: TimeInterval) -> Int {
191-
let derived = measuredRoundTrip * Double(alertResolutionReadsBeforeActivation) * alertCommandSafetyFactor
192-
return Int((min(max(derived, defaultAlertCommandTimeout), alertCommandTimeoutCap) * 1000).rounded())
193-
}
126+
/// Resolution reads the alert, its owner and every candidate button before anything is activated,
127+
/// and a contended hosted simulator has spent 40 s of reads there. These fixtures prove what
128+
/// activation and verification do, so they resolve first under this allowance, which only a failed
129+
/// resolution ever spends.
130+
static let alertResolutionAllowance: TimeInterval = 60
194131

195-
/// Samples the read the command is about to pay 24 times, once the alert is up, so the sample comes
196-
/// from the window the command runs in. Two samples and the slower one wins: a single lucky read
197-
/// must not size the budget low, and this is a worst-of measurement, not a mean.
198-
private func measuredAccessibilityRoundTrip() -> TimeInterval {
199-
let recordedActions = app.staticTexts["agent-device-alert-actions"]
200-
var roundTrip = TimeInterval(0.001)
201-
for _ in 0..<2 {
202-
let startedAt = Date()
203-
_ = recordedActions.label
204-
roundTrip = max(roundTrip, Date().timeIntervalSince(startedAt))
205-
}
206-
return roundTrip
132+
/// The deadline activation and verification run under once the alert is resolved: a confirmed
133+
/// answer returns as soon as verification sees the replacement, so only a fixture whose answer is
134+
/// the deadline itself pays it in full. It buys the dozen reads after resolution 2.5 s each, above the
135+
/// 1.7 s a read cost on the worst hosted nights traced (#2708).
136+
static let alertActivationDeadline: TimeInterval = 30
137+
138+
private func resolveAlertBeforeTheCommand() throws -> RunnerAlert {
139+
try XCTUnwrap(resolveAlert(app: app, deadline: Date().addingTimeInterval(RunnerTests.alertResolutionAllowance)))
207140
}
208141

209142
private func assertReplacementAlertUntouched(action: String, arguments: [String], confirmed: Bool) throws {
210143
app.launchArguments = ["--agent-device-alert-replacement-regression"] + arguments
211144
app.launch()
212145
defer {
146+
alertResolutionOverrideForTesting = nil
213147
invalidateCachedTarget(reason: "unit_test_cleanup")
214148
app.terminate()
215149
}
216150
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
217-
let measuredRoundTrip = measuredAccessibilityRoundTrip()
218-
let timeoutMs = RunnerTests.alertReplacementCommandTimeoutMs(measuredRoundTrip: measuredRoundTrip)
151+
let original = try resolveAlertBeforeTheCommand()
152+
alertResolutionOverrideForTesting = { _ in original }
153+
let timeoutMs = Int(RunnerTests.alertActivationDeadline * 1000)
219154
let command = try runnerCommandFixture(
220155
#"{"command":"alert","commandId":"alert-replacement","action":"\#(action)","timeoutMs":\#(timeoutMs)}"#
221156
)
222157
let response = try executeOnMainPrepared(command: command, activeApp: app)
223-
let budget = "timeoutMs \(timeoutMs) from a \(String(format: "%.3f", measuredRoundTrip))s read"
224-
XCTAssertEqual(response.ok, confirmed, "\(budget): \(String(describing: response.error))")
158+
alertResolutionOverrideForTesting = nil
159+
XCTAssertEqual(response.ok, confirmed, String(describing: response.error))
225160
if !confirmed { XCTAssertEqual(response.error?.code, "ALERT_DEADLINE_EXCEEDED") }
226161
XCTAssertTrue(app.alerts.firstMatch.exists, "the replacement must remain visible")
227162
XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 1; replacement actions: 0")
228-
let current = try XCTUnwrap(resolveAlert(app: app, deadline: Date().addingTimeInterval(10)))
229-
let inspection = handleAlert(current, action: "get", deadline: Date().addingTimeInterval(10))
163+
let current = try resolveAlertBeforeTheCommand()
164+
let inspection = handleAlert(current, action: "get", deadline: Date().addingTimeInterval(RunnerTests.alertActivationDeadline))
230165
XCTAssertTrue(inspection.ok)
231166
XCTAssertEqual(inspection.data?.items?.sorted(), ["Cancel", "OK"])
232167
XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 1; replacement actions: 0")

0 commit comments

Comments
 (0)