appium-webdriveragent 16.7.2 → 16.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +0 -10
  3. package/WebDriverAgentLib/Categories/XCAXClient_iOS+FBSnapshotReqParams.m +90 -0
  4. package/WebDriverAgentLib/Commands/FBCustomCommands.m +4 -1
  5. package/WebDriverAgentLib/Commands/FBScreenshotCommands.m +2 -2
  6. package/WebDriverAgentLib/Commands/FBSessionCommands.m +6 -5
  7. package/WebDriverAgentLib/Info.plist +2 -2
  8. package/WebDriverAgentLib/Routing/FBHTTPServer.h +25 -1
  9. package/WebDriverAgentLib/Routing/FBHTTPServer.m +278 -92
  10. package/WebDriverAgentLib/Routing/FBRoute.h +9 -0
  11. package/WebDriverAgentLib/Routing/FBRoute.m +9 -0
  12. package/WebDriverAgentLib/Routing/FBSession.h +13 -0
  13. package/WebDriverAgentLib/Routing/FBSession.m +153 -22
  14. package/WebDriverAgentLib/Routing/FBTCPSocket.m +2 -1
  15. package/WebDriverAgentLib/Routing/FBWebServer.m +26 -1
  16. package/WebDriverAgentLib/Routing/RouteResponse.m +1 -1
  17. package/WebDriverAgentLib/Utilities/FBConfiguration.h +11 -0
  18. package/WebDriverAgentLib/Utilities/FBConfiguration.m +8 -10
  19. package/WebDriverAgentLib/Utilities/FBSettings.h +1 -0
  20. package/WebDriverAgentLib/Utilities/FBSettings.m +1 -0
  21. package/WebDriverAgentLib/Utilities/FBSettingsHandler.m +7 -0
  22. package/WebDriverAgentLib/Utilities/FBXCAXClientProxy.h +9 -0
  23. package/WebDriverAgentLib/Utilities/FBXCAXClientProxy.m +26 -18
  24. package/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m +10 -7
  25. package/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m +36 -15
  26. package/WebDriverAgentTests/IntegrationApp/Classes/ViewController.m +4 -3
  27. package/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard +0 -1
  28. package/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m +34 -0
  29. package/package.json +1 -1
@@ -34,12 +34,23 @@ NSString *const FBDefaultApplicationAuto = @"auto";
34
34
 
35
35
  NSString *const FB_SAFARI_BUNDLE_ID = @"com.apple.mobilesafari";
36
36
 
37
+ // FBXCAXClientProxy's shared accessibility channel can be stuck servicing another request.
38
+ static const NSTimeInterval FB_IS_SYSTEM_APP_CHECK_TIMEOUT_SEC = 5.;
39
+ // -terminate hard-asserts off the main thread, which may itself be busy - see -fb_terminate...:.
40
+ static const NSTimeInterval FB_APP_TERMINATE_TIMEOUT_SEC = 5.;
41
+ // How long a -kill caller that lost the race below waits for the winner's teardown to finish.
42
+ static const NSTimeInterval FB_KILL_WAIT_TIMEOUT_SEC = 35.;
43
+ NSString *const FBSessionWasKilledNotification = @"FBSessionWasKilledNotification";
44
+
37
45
  @interface FBSession ()
38
46
  @property (nullable, nonatomic) XCUIApplication *testedApplication;
39
47
  @property (nonatomic) BOOL isTestedApplicationExpectedToRun;
40
48
  @property (nonatomic) BOOL shouldAppsWaitForQuiescence;
41
49
  @property (nonatomic, nullable) FBAlertsMonitor *alertsMonitor;
42
50
  @property (nonatomic, readwrite) NSMutableDictionary<NSNumber *, NSMutableDictionary<NSString *, NSNumber *> *> *elementsVisibilityCache;
51
+
52
+ - (BOOL)fb_isTestedApplicationSameAsSystemAppWithTimeout:(NSTimeInterval)timeout;
53
+ - (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout;
43
54
  @end
44
55
 
45
56
  @interface FBSession (FBAlertsMonitorDelegate)
@@ -90,17 +101,54 @@ NSString *const FB_SAFARI_BUNDLE_ID = @"com.apple.mobilesafari";
90
101
  @implementation FBSession
91
102
 
92
103
  static FBSession *_activeSession = nil;
104
+ // Class-level, not per-instance: a caller that finds _activeSession already nil (a concurrent
105
+ // -kill beat it there) still needs to know whether that -kill's teardown is done, since it cleared
106
+ // the pointer before running it. See +waitForActiveTeardownWithTimeout:.
107
+ static BOOL _isTeardownInProgress = NO;
108
+
109
+ + (NSCondition *)teardownCondition
110
+ {
111
+ static NSCondition *condition;
112
+ static dispatch_once_t onceToken;
113
+ dispatch_once(&onceToken, ^{
114
+ condition = [NSCondition new];
115
+ });
116
+ return condition;
117
+ }
118
+
119
+ // Waits (bounded) for any -kill teardown currently in progress to finish.
120
+ + (void)waitForActiveTeardownWithTimeout:(NSTimeInterval)timeout
121
+ {
122
+ NSCondition *condition = self.teardownCondition;
123
+ [condition lock];
124
+ NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout];
125
+ while (_isTeardownInProgress && [condition waitUntilDate:deadline]) {
126
+ }
127
+ [condition unlock];
128
+ }
93
129
 
94
130
  + (instancetype)activeSession
95
131
  {
96
132
  return _activeSession;
97
133
  }
98
134
 
99
- + (void)markSessionActive:(FBSession *)session
135
+ + (void)killActiveSessionAndWaitForTeardown
100
136
  {
101
- if (_activeSession) {
102
- [_activeSession kill];
137
+ FBSession *session = _activeSession;
138
+ if (nil != session) {
139
+ // Runs the real teardown synchronously if this call wins the race in -kill, or waits for
140
+ // whoever did to finish if it lost - either way, blocks until torn down.
141
+ [session kill];
142
+ } else {
143
+ // _activeSession is already nil, but a concurrent -kill (e.g. from DELETE /session) may still
144
+ // be mid-teardown - wait for it, so we don't launch a replacement app too early.
145
+ [self waitForActiveTeardownWithTimeout:FB_KILL_WAIT_TIMEOUT_SEC];
103
146
  }
147
+ }
148
+
149
+ + (void)markSessionActive:(FBSession *)session
150
+ {
151
+ [self killActiveSessionAndWaitForTeardown];
104
152
  _activeSession = session;
105
153
  }
106
154
 
@@ -169,33 +217,58 @@ static FBSession *_activeSession = nil;
169
217
 
170
218
  - (void)kill
171
219
  {
172
- if (nil == _activeSession) {
220
+ // DELETE /session and session creation can now run concurrently, so a session already
221
+ // superseded by a newer one can still reach here via a stale reference. Check-and-clear must be
222
+ // atomic, else a belated -kill could null out the new session's pointer instead of its own.
223
+ BOOL wasActive;
224
+ @synchronized (self.class) {
225
+ wasActive = (self == _activeSession);
226
+ if (wasActive) {
227
+ _activeSession = nil;
228
+ }
229
+ }
230
+ if (!wasActive) {
231
+ // Someone else is already tearing this session down - wait for that to finish (bounded), so
232
+ // we don't act as if it's gone (e.g. launch a new app) while its -terminate is still in flight.
233
+ [self.class waitForActiveTeardownWithTimeout:FB_KILL_WAIT_TIMEOUT_SEC];
173
234
  return;
174
235
  }
175
236
 
176
- [self disableAlertsMonitor];
237
+ NSCondition *teardownCondition = self.class.teardownCondition;
238
+ [teardownCondition lock];
239
+ _isTeardownInProgress = YES;
240
+ [teardownCondition unlock];
241
+
242
+ @try {
243
+ // Posted before teardown so pending HTTP requests for this session can stop waiting sooner.
244
+ [NSNotificationCenter.defaultCenter postNotificationName:FBSessionWasKilledNotification object:self];
177
245
 
178
- FBScreenRecordingPromise *activeScreenRecording = FBScreenRecordingContainer.sharedInstance.screenRecordingPromise;
179
- if (nil != activeScreenRecording) {
180
- NSError *error;
181
- if (![FBXCTestDaemonsProxy stopScreenRecordingWithUUID:activeScreenRecording.identifier error:&error]) {
182
- [FBLogger logFmt:@"%@", error];
246
+ [self disableAlertsMonitor];
247
+
248
+ FBScreenRecordingPromise *activeScreenRecording = FBScreenRecordingContainer.sharedInstance.screenRecordingPromise;
249
+ if (nil != activeScreenRecording) {
250
+ NSError *error;
251
+ if (![FBXCTestDaemonsProxy stopScreenRecordingWithUUID:activeScreenRecording.identifier error:&error]) {
252
+ [FBLogger logFmt:@"%@", error];
253
+ }
254
+ [FBScreenRecordingContainer.sharedInstance reset];
183
255
  }
184
- [FBScreenRecordingContainer.sharedInstance reset];
185
- }
186
256
 
187
- if (nil != self.testedApplication
188
- && FBConfiguration.sharedInstance.shouldTerminateApp
189
- && self.testedApplication.running
190
- && ![self.testedApplication fb_isSameAppAs:XCUIApplication.fb_systemApplication]) {
191
- @try {
192
- [self.testedApplication terminate];
193
- } @catch (NSException *e) {
194
- [FBLogger logFmt:@"%@", e.description];
257
+ if (nil != self.testedApplication
258
+ && FBConfiguration.sharedInstance.shouldTerminateApp
259
+ && self.testedApplication.running
260
+ && ![self fb_isTestedApplicationSameAsSystemAppWithTimeout:FB_IS_SYSTEM_APP_CHECK_TIMEOUT_SEC]) {
261
+ // Blocks until the app is either actually terminated or durably given up on (never left
262
+ // pending) - see -fb_terminateTestedApplicationWithTimeout: - so it's safe to report this
263
+ // teardown as finished as soon as this returns.
264
+ [self fb_terminateTestedApplicationWithTimeout:FB_APP_TERMINATE_TIMEOUT_SEC];
195
265
  }
266
+ } @finally {
267
+ [teardownCondition lock];
268
+ _isTeardownInProgress = NO;
269
+ [teardownCondition broadcast];
270
+ [teardownCondition unlock];
196
271
  }
197
-
198
- _activeSession = nil;
199
272
  }
200
273
 
201
274
  - (XCUIApplication *)activeApplication
@@ -290,4 +363,62 @@ static FBSession *_activeSession = nil;
290
363
  : [[XCUIApplication alloc] initWithBundleIdentifier:bundleIdentifier];
291
364
  }
292
365
 
366
+ // Has no async variant and can block on the shared accessibility channel. Run off-thread and give
367
+ // up after `timeout`, assuming the tested app IS the system app - safer, since it means skipping
368
+ // termination rather than risking terminating springboard.
369
+ - (BOOL)fb_isTestedApplicationSameAsSystemAppWithTimeout:(NSTimeInterval)timeout
370
+ {
371
+ __block XCUIApplication *systemApp = nil;
372
+ __block NSException *caughtException = nil;
373
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
374
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
375
+ // Undocumented private API; guard in case it hard-asserts off-main like -terminate does on
376
+ // some Xcode/iOS version - uncaught, that would crash the whole process.
377
+ @try {
378
+ systemApp = XCUIApplication.fb_systemApplication;
379
+ } @catch (NSException *e) {
380
+ caughtException = e;
381
+ }
382
+ dispatch_semaphore_signal(sem);
383
+ });
384
+ int64_t timeoutNs = (int64_t)(timeout * NSEC_PER_SEC);
385
+ if (0 != dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, timeoutNs)) || nil != caughtException) {
386
+ [FBLogger logFmt:@"Could not determine the system application within %@ seconds%@; assuming '%@' might be it and skipping its termination", @(timeout), nil == caughtException ? @"" : [NSString stringWithFormat:@" (%@)", caughtException.description], self.testedApplication.bundleID];
387
+ return YES;
388
+ }
389
+ return [self.testedApplication fb_isSameAppAs:systemApp];
390
+ }
391
+
392
+ // -terminate hard-asserts off-main, but -kill can now run on a background queue. Dispatching to
393
+ // main and waiting indefinitely could hang just as long as main is stuck, so give up after
394
+ // `timeout` - but a "given up on" call must never still terminate whatever's running by the time
395
+ // main gets to it (e.g. a replacement session's app), so cancellation and the actual terminate
396
+ // call share a lock: whichever gets there first - the dispatched block, or the timeout - wins.
397
+ - (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout
398
+ {
399
+ XCUIApplication *application = self.testedApplication;
400
+ NSObject *lock = [NSObject new];
401
+ __block BOOL isAllowedToTerminate = YES;
402
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
403
+ dispatch_async(dispatch_get_main_queue(), ^{
404
+ @synchronized (lock) {
405
+ if (isAllowedToTerminate) {
406
+ @try {
407
+ [application terminate];
408
+ } @catch (NSException *e) {
409
+ [FBLogger logFmt:@"%@", e.description];
410
+ }
411
+ }
412
+ }
413
+ dispatch_semaphore_signal(sem);
414
+ });
415
+ int64_t timeoutNs = (int64_t)(timeout * NSEC_PER_SEC);
416
+ if (0 != dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, timeoutNs))) {
417
+ @synchronized (lock) {
418
+ isAllowedToTerminate = NO;
419
+ }
420
+ [FBLogger logFmt:@"Could not terminate '%@' within %@ seconds; giving up on it rather than risk terminating a possible replacement session's app later", application.bundleID, @(timeout)];
421
+ }
422
+ }
423
+
293
424
  @end
@@ -135,7 +135,8 @@
135
135
  }
136
136
  [strongSelf scheduleReceiveForConnection:connection];
137
137
  } else if (nw_connection_state_failed == state || nw_connection_state_cancelled == state) {
138
- [weakSelf handleDisconnectForConnection:connection];
138
+ __strong typeof(weakSelf) strongSelf = weakSelf;
139
+ [strongSelf handleDisconnectForConnection:connection];
139
140
  }
140
141
  });
141
142
  nw_connection_start(connection);
@@ -13,8 +13,10 @@
13
13
  #import "FBTCPSocket.h"
14
14
 
15
15
  #import "FBCommandHandler.h"
16
+ #import "FBCommandStatus.h"
16
17
  #import "FBErrorBuilder.h"
17
18
  #import "FBExceptionHandler.h"
19
+ #import "FBResponsePayload.h"
18
20
  #import "FBRouteRequest.h"
19
21
  #import "FBRuntimeUtils.h"
20
22
  #import "FBSession.h"
@@ -80,6 +82,11 @@ static NSString *const FBServerURLEndMarker = @"<-ServerURLHere";
80
82
  [self.server setDefaultHeader:@"Access-Control-Allow-Origin" value:@"*"];
81
83
  [self.server setDefaultHeader:@"Access-Control-Allow-Headers" value:@"Content-Type, X-Requested-With"];
82
84
 
85
+ [NSNotificationCenter.defaultCenter addObserver:self
86
+ selector:@selector(sessionWasKilled:)
87
+ name:FBSessionWasKilledNotification
88
+ object:nil];
89
+
83
90
  [self registerRouteHandlers:[self.class collectCommandHandlerClasses]];
84
91
  [self registerServerKeyRouteHandlers];
85
92
 
@@ -167,8 +174,26 @@ static NSString *const FBServerURLEndMarker = @"<-ServerURLHere";
167
174
  }
168
175
  }
169
176
 
177
+ - (void)sessionWasKilled:(NSNotification *)notification
178
+ {
179
+ FBSession *session = notification.object;
180
+ if (![session isKindOfClass:FBSession.class]) {
181
+ return;
182
+ }
183
+ // Same "invalid session id" shape a still-queued request would eventually get anyway, once
184
+ // -routeQueue drains and FBRoute.decorateRequest: finds the session gone - just delivered now
185
+ // instead of after however long the request would otherwise have been stuck waiting.
186
+ NSString *message = [NSString stringWithFormat:@"Session %@ was deleted while this request was still pending", session.identifier];
187
+ id<FBResponsePayload> payload = FBResponseWithStatus([FBCommandStatus noSuchDriverErrorWithMessage:message
188
+ traceback:nil]);
189
+ RouteResponse *response = [RouteResponse new];
190
+ [payload dispatchWithResponse:response];
191
+ [self.server abandonPendingRequestsForSessionID:session.identifier withResponse:response];
192
+ }
193
+
170
194
  - (void)stopServing
171
195
  {
196
+ [NSNotificationCenter.defaultCenter removeObserver:self name:FBSessionWasKilledNotification object:nil];
172
197
  [FBSession.activeSession kill];
173
198
  [self stopScreenshotsBroadcaster];
174
199
  if (self.server.isRunning) {
@@ -208,7 +233,7 @@ static NSString *const FBServerURLEndMarker = @"<-ServerURLHere";
208
233
  for (Class<FBCommandHandler> commandHandler in commandHandlerClasses) {
209
234
  NSArray *routes = [commandHandler routes];
210
235
  for (FBRoute *route in routes) {
211
- [self.server handleMethod:route.verb withPath:route.path block:^(RouteRequest *request, RouteResponse *response) {
236
+ [self.server handleMethod:route.verb withPath:route.path standalone:route.isStandalone block:^(RouteRequest *request, RouteResponse *response) {
212
237
  __strong typeof(weakSelf) strongSelf = weakSelf;
213
238
  if (nil == strongSelf) {
214
239
  return;
@@ -9,7 +9,7 @@
9
9
  #import "RouteResponse.h"
10
10
 
11
11
  @interface RouteResponse ()
12
- @property (nonatomic, copy) NSMutableDictionary<NSString *, NSString *> *mutableHeaders;
12
+ @property (nonatomic, strong) NSMutableDictionary<NSString *, NSString *> *mutableHeaders;
13
13
  @end
14
14
 
15
15
  @implementation RouteResponse
@@ -234,6 +234,17 @@ typedef NS_ENUM(NSInteger, FBConfigurationKeyboardPreference) {
234
234
  */
235
235
  @property (atomic, assign) NSTimeInterval animationCoolOffTimeout;
236
236
 
237
+ /**
238
+ * Maximum time to wait for the frontmost application to confirm its main run loop
239
+ * is responsive before an accessibility snapshot request (element attribute
240
+ * lookups, active app detection, etc). XCTest has no bounded timeout of its own
241
+ * here, so a frozen app could otherwise block WDA forever (#1210); past this
242
+ * timeout the request is aborted with an error instead.
243
+ * Set to zero or negative to disable, restoring unbounded behavior. Disabled (0)
244
+ * by default.
245
+ */
246
+ @property (atomic, assign) NSTimeInterval accessibilityDeadline;
247
+
237
248
  /**
238
249
  Custom class chain locator for accept alert button location.
239
250
  This might be useful if the default buttons detection algorithm fails to determine alert buttons properly
@@ -124,17 +124,14 @@ static NSString *const axSettingsClassName = @"AXSettings";
124
124
  {
125
125
  // 'WebDriverAgent --port 8080' can be passed via the arguments to the process
126
126
  NSRange rangeFromArguments = [self.class bindingPortRangeFromArguments];
127
- if (rangeFromArguments.location != NSNotFound) {
128
- return rangeFromArguments;
127
+ if (rangeFromArguments.location == NSNotFound) {
128
+ // Existence of USE_PORT in the environment implies the port range is managed by the launching process.
129
+ NSString *usePort = NSProcessInfo.processInfo.environment[@"USE_PORT"];
130
+ rangeFromArguments = usePort.length > 0
131
+ ? NSMakeRange((NSUInteger)usePort.integerValue, 1)
132
+ : NSMakeRange(DefaultStartingPort, DefaultPortRange);
129
133
  }
130
-
131
- // Existence of USE_PORT in the environment implies the port range is managed by the launching process.
132
- if (NSProcessInfo.processInfo.environment[@"USE_PORT"] &&
133
- [NSProcessInfo.processInfo.environment[@"USE_PORT"] length] > 0) {
134
- return NSMakeRange([NSProcessInfo.processInfo.environment[@"USE_PORT"] integerValue] , 1);
135
- }
136
-
137
- return NSMakeRange(DefaultStartingPort, DefaultPortRange);
134
+ return rangeFromArguments;
138
135
  }
139
136
 
140
137
  - (NSString *)bindingIPAddress
@@ -354,6 +351,7 @@ static NSString *const axSettingsClassName = @"AXSettings";
354
351
  self.autoClickAlertSelector = @"";
355
352
  self.waitForIdleTimeout = 10.;
356
353
  self.animationCoolOffTimeout = 2.;
354
+ self.accessibilityDeadline = 0.;
357
355
  // 50 should be enough for the majority of the cases. The performance is acceptable for values up to 100.
358
356
  FBSetCustomParameterForElementSnapshot(FBSnapshotMaxDepthKey, @50);
359
357
  FBSetCustomParameterForElementSnapshot(FBSnapshotMaxChildrenKey, @INT_MAX);
@@ -23,6 +23,7 @@ extern NSString* const FB_SETTING_KEYBOARD_AUTOCORRECTION;
23
23
  extern NSString* const FB_SETTING_KEYBOARD_PREDICTION;
24
24
  extern NSString* const FB_SETTING_SNAPSHOT_MAX_DEPTH;
25
25
  extern NSString* const FB_SETTING_SNAPSHOT_MAX_CHILDREN;
26
+ extern NSString* const FB_SETTING_ACCESSIBILITY_DEADLINE;
26
27
  extern NSString* const FB_SETTING_USE_FIRST_MATCH;
27
28
  extern NSString* const FB_SETTING_BOUND_ELEMENTS_BY_INDEX;
28
29
  extern NSString* const FB_SETTING_REDUCE_MOTION;
@@ -19,6 +19,7 @@ NSString* const FB_SETTING_KEYBOARD_AUTOCORRECTION = @"keyboardAutocorrection";
19
19
  NSString* const FB_SETTING_KEYBOARD_PREDICTION = @"keyboardPrediction";
20
20
  NSString* const FB_SETTING_SNAPSHOT_MAX_DEPTH = @"snapshotMaxDepth";
21
21
  NSString* const FB_SETTING_SNAPSHOT_MAX_CHILDREN = @"snapshotMaxChildren";
22
+ NSString* const FB_SETTING_ACCESSIBILITY_DEADLINE = @"accessibilityDeadline";
22
23
  NSString* const FB_SETTING_USE_FIRST_MATCH = @"useFirstMatch";
23
24
  NSString* const FB_SETTING_BOUND_ELEMENTS_BY_INDEX = @"boundElementsByIndex";
24
25
  NSString* const FB_SETTING_REDUCE_MOTION = @"reduceMotion";
@@ -142,6 +142,10 @@ static NSSet<NSString *> *FBNilClearableSettingKeys(void)
142
142
  FBConfiguration.sharedInstance.animationCoolOffTimeout = [value doubleValue];
143
143
  return nil;
144
144
  };
145
+ map[FB_SETTING_ACCESSIBILITY_DEADLINE] = ^FBCommandStatus *(FBSession *session, id value) {
146
+ FBConfiguration.sharedInstance.accessibilityDeadline = [value doubleValue];
147
+ return nil;
148
+ };
145
149
  map[FB_SETTING_DEFAULT_ALERT_ACTION] = ^FBCommandStatus *(FBSession *session, id value) {
146
150
  if (nil == value) {
147
151
  session.defaultAlertAction = nil;
@@ -249,6 +253,9 @@ static NSSet<NSString *> *FBNilClearableSettingKeys(void)
249
253
  map[FB_SETTING_ANIMATION_COOL_OFF_TIMEOUT] = ^id(FBSession *session) {
250
254
  return @(FBConfiguration.sharedInstance.animationCoolOffTimeout);
251
255
  };
256
+ map[FB_SETTING_ACCESSIBILITY_DEADLINE] = ^id(FBSession *session) {
257
+ return @(FBConfiguration.sharedInstance.accessibilityDeadline);
258
+ };
252
259
  map[FB_SETTING_BOUND_ELEMENTS_BY_INDEX] = ^id(FBSession *session) {
253
260
  return @(FBConfiguration.sharedInstance.boundElementsByIndex);
254
261
  };
@@ -38,6 +38,15 @@ NS_ASSUME_NONNULL_BEGIN
38
38
  - (void)notifyWhenNoAnimationsAreActiveForApplication:(XCUIApplication *)application
39
39
  reply:(void (^)(void))reply;
40
40
 
41
+ /**
42
+ Wraps the private -[XCAXClient_iOS notifyWhenEventLoopIsIdleForApplication:reply:],
43
+ used to check run loop responsiveness before a snapshot request (#1210).
44
+ `reply` may fire more than once per call; `error` is non-nil only if monitoring
45
+ itself could not be started.
46
+ */
47
+ - (void)notifyWhenEventLoopIsIdleForApplication:(XCUIApplication *)application
48
+ reply:(void (^)(id _Nullable result, NSError * _Nullable error))reply;
49
+
41
50
  - (nullable NSDictionary *)attributesForElement:(id<FBXCAccessibilityElement>)element
42
51
  attributes:(NSArray *)attributes
43
52
  error:(NSError**)error;
@@ -81,6 +81,12 @@ static id FBAXClient = nil;
81
81
  [FBAXClient notifyWhenNoAnimationsAreActiveForApplication:application reply:reply];
82
82
  }
83
83
 
84
+ - (void)notifyWhenEventLoopIsIdleForApplication:(XCUIApplication *)application
85
+ reply:(void (^)(id _Nullable result, NSError * _Nullable error))reply
86
+ {
87
+ [FBAXClient notifyWhenEventLoopIsIdleForApplication:application reply:reply];
88
+ }
89
+
84
90
  - (NSDictionary *)attributesForElement:(id<FBXCAccessibilityElement>)element
85
91
  attributes:(NSArray *)attributes
86
92
  error:(NSError**)error;
@@ -92,28 +98,30 @@ static id FBAXClient = nil;
92
98
 
93
99
  - (XCUIApplication *)monitoredApplicationWithProcessIdentifier:(int)pid
94
100
  {
95
- NSMutableSet *terminatedAppIds = [NSMutableSet set];
96
- for (NSNumber *appPid in self.appsCache) {
97
- if (![self.appsCache[appPid] running]) {
98
- [terminatedAppIds addObject:appPid];
101
+ @synchronized (self) {
102
+ NSMutableSet *terminatedAppIds = [NSMutableSet set];
103
+ for (NSNumber *appPid in self.appsCache) {
104
+ if (![self.appsCache[appPid] running]) {
105
+ [terminatedAppIds addObject:appPid];
106
+ }
107
+ }
108
+ for (NSNumber *appPid in terminatedAppIds) {
109
+ [self.appsCache removeObjectForKey:appPid];
99
110
  }
100
- }
101
- for (NSNumber *appPid in terminatedAppIds) {
102
- [self.appsCache removeObjectForKey:appPid];
103
- }
104
111
 
105
- XCUIApplication *result = [self.appsCache objectForKey:@(pid)];
106
- if (nil != result) {
107
- return result;
108
- }
112
+ XCUIApplication *result = [self.appsCache objectForKey:@(pid)];
113
+ if (nil != result) {
114
+ return result;
115
+ }
109
116
 
110
- XCUIApplication *app = [[FBAXClient applicationProcessTracker]
111
- monitoredApplicationWithProcessIdentifier:pid];
112
- if (nil == app) {
113
- return nil;
117
+ XCUIApplication *app = [[FBAXClient applicationProcessTracker]
118
+ monitoredApplicationWithProcessIdentifier:pid];
119
+ if (nil == app) {
120
+ return nil;
121
+ }
122
+ [self.appsCache setObject:app forKey:@(pid)];
123
+ return app;
114
124
  }
115
- [self.appsCache setObject:app forKey:@(pid)];
116
- return app;
117
125
  }
118
126
 
119
127
  @end
@@ -23,6 +23,7 @@
23
23
  #import "XCUIDevice.h"
24
24
 
25
25
  #define LAUNCH_APP_TIMEOUT_SEC 300
26
+ #define STOP_SCREEN_RECORDING_TIMEOUT_SEC 20
26
27
 
27
28
  static void (*originalLaunchAppMethod)(id, SEL, NSString*, NSString*, NSArray*, NSDictionary*, void (^)(_Bool, NSError *));
28
29
 
@@ -342,14 +343,16 @@ static void swizzledLaunchApp(id self, SEL _cmd, NSString *path, NSString *bundl
342
343
  }
343
344
 
344
345
  __block NSError *innerError = nil;
345
- [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){
346
- [session stopScreenRecordingWithUUID:uuid withReply:^(NSError *invokeError) {
347
- if (nil != invokeError) {
348
- innerError = invokeError;
349
- }
350
- completion();
351
- }];
346
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
347
+ [session stopScreenRecordingWithUUID:uuid withReply:^(NSError *invokeError) {
348
+ innerError = invokeError;
349
+ dispatch_semaphore_signal(sem);
352
350
  }];
351
+ int64_t timeoutNs = (int64_t)(STOP_SCREEN_RECORDING_TIMEOUT_SEC * NSEC_PER_SEC);
352
+ if (0 != dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, timeoutNs)) && nil == innerError) {
353
+ NSString *message = [NSString stringWithFormat:@"Did not receive a reply to stop screen recording within %d seconds", STOP_SCREEN_RECORDING_TIMEOUT_SEC];
354
+ innerError = [[[FBErrorBuilder builder] withDescription:message] build];
355
+ }
353
356
  if (nil != innerError && error) {
354
357
  *error = innerError;
355
358
  }
@@ -77,32 +77,53 @@
77
77
 
78
78
  @end
79
79
 
80
+ #define TESTMANAGERD_VERSION_TIMEOUT_SEC 20
81
+
80
82
  NSInteger FBTestmanagerdVersion(void)
81
83
  {
82
- static dispatch_once_t getTestmanagerdVersion;
83
- static NSInteger testmanagerdVersion;
84
- dispatch_once(&getTestmanagerdVersion, ^{
84
+ // Not dispatch_once: that would permanently cache the timeout fallback below if the first call's
85
+ // reply merely arrived late. -1 means "not yet determined"; a timeout isn't cached, so it retries.
86
+ static NSInteger cachedVersion = -1;
87
+ static dispatch_queue_t syncQueue;
88
+ static dispatch_once_t onceToken;
89
+ dispatch_once(&onceToken, ^{
90
+ syncQueue = dispatch_queue_create("com.facebook.wda.testmanagerdVersion", DISPATCH_QUEUE_SERIAL);
91
+ });
92
+
93
+ __block NSInteger result;
94
+ dispatch_sync(syncQueue, ^{
95
+ if (cachedVersion >= 0) {
96
+ result = cachedVersion;
97
+ return;
98
+ }
99
+
85
100
  id<XCTMessagingChannel_RunnerToDaemon> proxy = [FBXCTestDaemonsProxy testRunnerProxy];
86
101
  if ([(NSObject *)proxy respondsToSelector:@selector(_XCT_exchangeProtocolVersion:reply:)]) {
87
102
  id<FBXCTestManagerLegacyProtocolVersionExchanging> legacyProxy = (id<FBXCTestManagerLegacyProtocolVersionExchanging>)proxy;
88
- [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){
89
- [legacyProxy _XCT_exchangeProtocolVersion:testmanagerdVersion reply:^(unsigned long long code) {
90
- testmanagerdVersion = (NSInteger) code;
91
- completion();
92
- }];
103
+ __block NSInteger receivedVersion = -1;
104
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
105
+ [legacyProxy _XCT_exchangeProtocolVersion:0 reply:^(unsigned long long code) {
106
+ receivedVersion = (NSInteger) code;
107
+ dispatch_semaphore_signal(sem);
93
108
  }];
109
+ int64_t timeoutNs = (int64_t)(TESTMANAGERD_VERSION_TIMEOUT_SEC * NSEC_PER_SEC);
110
+ if (0 != dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, timeoutNs))) {
111
+ // Assume newest/full-featured on timeout, but don't cache it - retry on the next call.
112
+ [FBLogger logFmt:@"Did not receive a testmanagerd protocol version reply within %d seconds; assuming the newest/full-featured protocol", TESTMANAGERD_VERSION_TIMEOUT_SEC];
113
+ result = 0xFFFF;
114
+ return;
115
+ }
116
+ result = receivedVersion;
94
117
  } else {
95
- // Modern testmanagerd (Xcode 15+) has already negotiated named XCTCapabilities by the time
96
- // a daemon session exists, instead of a single scalar protocol version. There is no direct
97
- // integer equivalent to report here (this value is diagnostic-only, surfaced via the
98
- // 'testmanagerdVersion' session capability), so keep reporting the existing "assume
99
- // newest/full-featured" sentinel, while confirming capabilities did negotiate successfully.
118
+ // Modern testmanagerd (Xcode 15+) negotiates named XCTCapabilities instead of a scalar
119
+ // version; there's no direct integer equivalent, so just confirm capabilities negotiated.
100
120
  XCTCapabilities *capabilities = [XCTRunnerDaemonSession sharedSession].remoteInterfaceCapabilities;
101
121
  if (nil == capabilities) {
102
122
  [FBLogger log:@"Could not retrieve testmanagerd capabilities"];
103
123
  }
104
- testmanagerdVersion = 0xFFFF;
124
+ result = 0xFFFF;
105
125
  }
126
+ cachedVersion = result;
106
127
  });
107
- return testmanagerdVersion;
128
+ return result;
108
129
  }
@@ -38,9 +38,10 @@
38
38
 
39
39
  - (IBAction)deadlockApp:(id)sender
40
40
  {
41
- dispatch_sync(dispatch_get_main_queue(), ^{
42
- // This will never execute
43
- });
41
+ // A self dispatch_sync would trip the OS watchdog and get the process
42
+ // killed outright. Sleeping instead simulates an app that stops answering
43
+ // accessibility requests while staying alive, per #1210.
44
+ [NSThread sleepForTimeInterval:20.0];
44
45
  }
45
46
 
46
47
  - (IBAction)didTapButton:(UIButton *)button
@@ -38,7 +38,6 @@
38
38
  <state key="normal" title="Deadlock app"/>
39
39
  <connections>
40
40
  <action selector="deadlockApp:" destination="BYZ-38-t0r" eventType="touchUpInside" id="53X-DJ-KNY"/>
41
- <action selector="showAlert:" destination="BYZ-38-t0r" eventType="touchUpInside" id="FEN-VX-MMc"/>
42
41
  </connections>
43
42
  </button>
44
43
  <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="M2N-Yn-ytb">
@@ -11,6 +11,9 @@
11
11
 
12
12
  #import "FBConfiguration.h"
13
13
  #import "FBRuntimeUtils.h"
14
+ #import "FBTestMacros.h"
15
+ #import "XCUIElement.h"
16
+ #import "XCUIElement+FBIsVisible.h"
14
17
 
15
18
  @interface FBConfigurationTests : FBIntegrationTestCase
16
19
 
@@ -35,4 +38,35 @@
35
38
  XCTAssertEqual(FBConfiguration.sharedInstance.reduceMotionEnabled, defaultReduceMotionEnabled);
36
39
  }
37
40
 
41
+ - (void)testAccessibilityDeadlineAbortsSnapshotRequestForDeadlockedApp
42
+ {
43
+ if (nil != NSProcessInfo.processInfo.environment[@"CI"]) {
44
+ XCTSkip(@"Deliberately freezes the app for several seconds, too slow/flaky for CI");
45
+ }
46
+
47
+ NSTimeInterval previousDeadline = FBConfiguration.sharedInstance.accessibilityDeadline;
48
+ // Also bounds any snapshot-based wait -tap itself may perform once the app is stuck.
49
+ FBConfiguration.sharedInstance.accessibilityDeadline = 3.0;
50
+ @try {
51
+ XCUIElement *deadlockButton = self.testedApplication.buttons[@"Deadlock app"];
52
+ FBAssertWaitTillBecomesTrue(deadlockButton.fb_isVisible);
53
+ // Freezes the app's main thread for 20s - see -[ViewController deadlockApp:].
54
+ [deadlockButton tap];
55
+
56
+ NSError *error;
57
+ NSDate *start = [NSDate date];
58
+ id snapshot = [self.testedApplication snapshotWithError:&error];
59
+ NSTimeInterval elapsed = -start.timeIntervalSinceNow;
60
+
61
+ XCTAssertNil(snapshot);
62
+ XCTAssertNotNil(error);
63
+ // Should abort close to accessibilityDeadline (plus XCTest's own internal
64
+ // retries), not hang indefinitely waiting for the frozen app (#1210).
65
+ XCTAssertLessThan(elapsed, 20.0);
66
+ } @finally {
67
+ FBConfiguration.sharedInstance.accessibilityDeadline = previousDeadline;
68
+ [self.testedApplication terminate];
69
+ }
70
+ }
71
+
38
72
  @end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-webdriveragent",
3
- "version": "16.7.2",
3
+ "version": "16.8.0",
4
4
  "description": "Package bundling WebDriverAgent",
5
5
  "keywords": [
6
6
  "Appium",