Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,10 @@ jobs:
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertAcceptDoesNotActivateAReplacementWithASharedButton \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDismissDoesNotActivateAReplacementWithTheSameTitle \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertReplacementCommandDeadlineScalesWithMeasuredLatency \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDeadlineBeforeActivationLeavesTheOriginalUntouched \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertHittableProbeCompletingAfterDeadlineLeavesTheOriginalUntouched \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertActivationIgnoresAnAppThatNeverSettlesBeforeTheDeadline \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertActivationDoesNotWaitOutANotificationBanner \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain \
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
CI it spent its whole 10 s budget there (`wait_capture_stalled` with `captures: 1`). The probe now
joins the running discovery, bounded by the discovery's own deadline, and then observes the launch
as before. A resolution failure other than a pending discovery still ends the probe at once.
- Fixed (ios): `alert accept` and `alert dismiss` no longer land their tap after the command's
deadline when a notification banner is on screen. XCTest checks for SpringBoard banners and alerts
before every event and its default handler waited up to 15 s for a banner to leave, so the command
answered `ALERT_DEADLINE_EXCEEDED` ("The button was activated once") for a tap that arrived after
the caller was told it had failed (#2546's late tap, from the banner side). Alert activation now
opts out of XCTest's interruption handling, which also stops that handler from pressing a button of
its own choosing on an unrelated system alert while the command answers the alert it resolved.
- Fixed (ios): a local Simulator snapshot taken through the host AX bridge once again publishes the
geometric `hittable` fact, so `is hittable` and a `hittable:` selector resolve the same controls on
the bridge and the XCTest runner. The snapshot capability table has declared `hittable =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,31 +56,47 @@ int main(int argc, const char *argv[]) {

#else
#import <UIKit/UIKit.h>
#if TARGET_OS_IOS
#import <UserNotifications/UserNotifications.h>
#endif

@interface AgentDeviceRunnerViewController : UIViewController
@property(nonatomic, strong) UILabel *alertActionStatus;
@property(nonatomic, strong) UILabel *alertActivationBusyAnswer;
@property(nonatomic, assign) NSUInteger firstAlertActions;
@property(nonatomic, assign) NSUInteger replacementAlertActions;
@property(nonatomic, assign) BOOL alertFixtureStarted;
@property(nonatomic, strong) NSTimer *alertActivationBusyBackstop;
@property(nonatomic, strong) NSTimer *alertBannerRepost;
@end

#if TARGET_OS_IOS
@interface AgentDeviceRunnerViewController () <UNUserNotificationCenterDelegate>
@end
#endif

@implementation AgentDeviceRunnerViewController

#if TARGET_OS_IOS
// An animation that never ends is what "busy" looks like to XCTest while it decides whether the app
// may receive an event: the app keeps reporting work in flight, which is the state that cost an alert
// command its whole deadline in #2546. It stops the moment an alert button is answered, since that
// answer is the event the runner is trying to land, and the backstop stops it even when no answer
// arrives so a regressed run finishes rather than waiting out XCTest's own timeout. A layer
// arrives so a regressed run finishes rather than waiting out XCTest's own timeout. The test passes
// the backstop after `--agent-device-alert-activation-busy`, sized to outlast its whole resolution
// and activation budget, so a slow host cannot end the busy state before the answer lands. A layer
// animation on its own is not enough; only a UIView animation counts as in-flight work here.
static NSTimeInterval const kAgentDeviceAlertActivationBusyWindow = 20.0;
static NSTimeInterval AgentDeviceAlertActivationBusyWindow(void) {
NSArray<NSString *> *arguments = NSProcessInfo.processInfo.arguments;
NSUInteger flag = [arguments indexOfObject:@"--agent-device-alert-activation-busy"];
return flag + 1 < arguments.count ? arguments[flag + 1].doubleValue : 0;
}

- (void)startAlertActivationBusy {
if (self.alertActivationBusyBackstop != nil) {
return;
}
self.alertActivationBusyBackstop = [NSTimer scheduledTimerWithTimeInterval:kAgentDeviceAlertActivationBusyWindow
self.alertActivationBusyBackstop = [NSTimer scheduledTimerWithTimeInterval:AgentDeviceAlertActivationBusyWindow()
target:self
selector:@selector(stopAlertActivationBusy)
userInfo:nil
Expand All @@ -101,6 +117,57 @@ - (void)stopAlertActivationBusy {
self.alertActivationBusyBackstop = nil;
}

// A banner from this app, shown over its own alert and re-posted before the previous one expires so
// one is on screen for as long as the first alert is unanswered. XCTest treats such a banner as an
// interruption of every event aimed at the app (#2546's late tap, from the banner side).
- (void)startAlertBannerThen:(dispatch_block_t)presentAlert {
UNUserNotificationCenter *center = UNUserNotificationCenter.currentNotificationCenter;
center.delegate = self;
[center requestAuthorizationWithOptions:UNAuthorizationOptionAlert
completionHandler:^(BOOL granted, NSError *error) {
(void)error;
if (!granted) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
presentAlert();
[self postAlertBanner];
self.alertBannerRepost = [NSTimer scheduledTimerWithTimeInterval:2.0
target:self
selector:@selector(postAlertBanner)
userInfo:nil
repeats:YES];
});
}];
}

- (void)postAlertBanner {
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
content.title = @"Agent Device banner";
content.body = @"Shown over the alert fixture";
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:NSUUID.UUID.UUIDString
content:content
trigger:nil];
[UNUserNotificationCenter.currentNotificationCenter addNotificationRequest:request withCompletionHandler:nil];
}

- (void)stopAlertBanner {
if (self.alertBannerRepost == nil) {
return;
}
[self.alertBannerRepost invalidate];
self.alertBannerRepost = nil;
[UNUserNotificationCenter.currentNotificationCenter removeAllDeliveredNotifications];
}

- (void)userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler {
(void)center;
(void)notification;
completionHandler(UNNotificationPresentationOptionBanner);
}


- (void)updateAlertActionStatus {
self.alertActionStatus.text = [NSString stringWithFormat:@"First actions: %lu; replacement actions: %lu",
Expand All @@ -123,7 +190,12 @@ - (void)presentAlertFixtureReplacement:(BOOL)replacement {
? UIAlertActionStyleCancel : UIAlertActionStyleDefault;
[alert addAction:[UIAlertAction actionWithTitle:buttonTitle style:style handler:^(UIAlertAction *action) {
(void)action;
if (!replacement) {
self.alertActivationBusyAnswer.text = self.alertActivationBusyBackstop != nil
? @"Answered while busy" : @"Answered after the app went idle";
}
[self stopAlertActivationBusy];
[self stopAlertBanner];
if (replacement) {
self.replacementAlertActions += 1;
} else {
Expand All @@ -145,9 +217,16 @@ - (void)viewDidAppear:(BOOL)animated {
if (!self.alertFixtureStarted &&
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-replacement-regression"]) {
self.alertFixtureStarted = YES;
[self presentAlertFixtureReplacement:NO];
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-activation-busy"]) {
[self startAlertActivationBusy];
dispatch_block_t presentAlert = ^{
[self presentAlertFixtureReplacement:NO];
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-activation-busy"]) {
[self startAlertActivationBusy];
}
};
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-banner"]) {
[self startAlertBannerThen:presentAlert];
} else {
presentAlert();
}
}
}
Expand Down Expand Up @@ -185,6 +264,19 @@ - (void)viewDidLoad {
[self updateAlertActionStatus];
}

if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-activation-busy"]) {
UILabel *busyAnswer = [[UILabel alloc] init];
busyAnswer.text = @"Unanswered";
busyAnswer.accessibilityIdentifier = @"agent-device-alert-busy-answer";
busyAnswer.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:busyAnswer];
[NSLayoutConstraint activateConstraints:@[
[busyAnswer.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
[busyAnswer.topAnchor constraintEqualToAnchor:label.bottomAnchor constant:24],
]];
self.alertActivationBusyAnswer = busyAnswer;
}

if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-regression"]) {
UITextField *textField = [[UITextField alloc] init];
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,17 @@ extension RunnerTests {
buttonFrame.origin.x, buttonFrame.origin.y, buttonFrame.size.width, buttonFrame.size.height,
buttonFrame.midX, buttonFrame.midY
)
// The hittable read above is this activation's readiness gate, so XCTest's pre-synthesis wait
// adds nothing and can cost more than the command has: the tap would land after the deadline
// The hittable read above is this activation's readiness gate, so XCTest's pre-synthesis waits
// add nothing and can cost more than the command has: the tap would land after the deadline
// expired and the alert would be answered by a button the caller was told nothing about
// (#2546). The post-tap settle stays, because the verification below reads the alert this tap
// replaces; an alert that dismisses and presents an identical replacement passes through a
// window with no alert, and a first read landing there reports a dismissal nothing proved.
var outcome = RunnerInteractionOutcome.performed
withBoundedInteractionIdleTimeoutIfSupported(alert.ownerApp, waits: .preEventSkipped) {
outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
withUIInterruptionHandlingDisabledIfSupported(alert.ownerApp) {
withBoundedInteractionIdleTimeoutIfSupported(alert.ownerApp, waits: .preEventSkipped) {
outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
}
}
if let response = unsupportedResponse(for: outcome) {
return response
Expand Down Expand Up @@ -114,6 +116,22 @@ extension RunnerTests {
)
}

/// Before each event XCTest looks for SpringBoard elements over the target and hands them to its
/// interruption handler, which waits up to 15 s for a notification banner to leave and taps a
/// button of its own choosing on any other alert. An alert command answers exactly the alert it
/// resolved, with the button it chose, before its deadline, so it opts out of both.
private func withUIInterruptionHandlingDisabledIfSupported(_ target: XCUIApplication, operation: () -> Void) {
let key = "doesNotHandleUIInterruptions"
guard target.responds(to: NSSelectorFromString("setDoesNotHandleUIInterruptions:")) else {
operation()
return
}
let previous = target.value(forKey: key) as? NSNumber
target.setValue(true, forKey: key)
defer { target.setValue(previous?.boolValue ?? false, forKey: key) }
operation()
}

private func runnerAlert(_ modal: ResolvedBlockingSystemModal) -> RunnerAlert? {
let buttons = modal.actions.filter { isEnabledElement($0) }
guard !buttons.isEmpty else {
Expand Down
Loading
Loading