Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
45 changes: 35 additions & 10 deletions WebDriverAgentLib/Commands/FBCustomCommands.m
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
#import "XCUIElement.h"
#import "XCUIElement+FBIsVisible.h"
#import "XCUIElementQuery.h"
#import "XCUIScreen.h"
#import "FBUnattachedAppLauncher.h"

@implementation FBCustomCommands
Expand Down Expand Up @@ -162,17 +163,41 @@ + (NSArray *)routes

+ (id<FBResponsePayload>)handleGetScreen:(FBRouteRequest *)request
{
NSError *error = nil;
XCUIScreen *screen = [FBScreen currentScreenWithError:&error];
if (nil == screen) {
return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:error.localizedDescription
traceback:nil]);
}
XCUIApplication *app = XCUIApplication.fb_systemApplication;

XCUIElement *mainStatusBar = app.statusBars.allElementsBoundByIndex.firstObject;
CGSize statusBarSize = (nil == mainStatusBar) ? CGSizeZero : mainStatusBar.frame.size;

#if TARGET_OS_TV || TARGET_OS_WATCH
CGSize screenSize = app.frame.size;
#else
CGSize screenSize = FBAdjustDimensionsForApplication(app.wdFrame.size, app.interfaceOrientation);
CGSize screenSize = CGSizeMake(CGRectGetWidth(screen.bounds) / screen.scale,
CGRectGetHeight(screen.bounds) / screen.scale);
#if !TARGET_OS_TV && !TARGET_OS_WATCH
XCUIApplication *activeApp = request.session ? request.session.activeApplication : XCUIApplication.fb_activeApplication;
screenSize = FBAdjustDimensionsForApplication(screenSize, activeApp.interfaceOrientation);
#endif

// SpringBoard can expose hidden status bars belonging to other displays, and
// Duo also exposes full-screen containers for its side-mounted status UI.
// Only a visible horizontal bar at the top of this display can be subtracted
// from a rectangular viewport.
CGSize statusBarSize = CGSizeZero;
for (XCUIElement *statusBar in app.statusBars.allElementsBoundByIndex) {
// Resolve the element before reading its screen: displayID can be zero on
// an unresolved query result even when the bar belongs to the main screen.
CGRect frame = statusBar.frame;
if (statusBar.screen.displayID != screen.displayID || !statusBar.fb_isVisible) {
continue;
}
if (fabs(CGRectGetMinY(frame)) <= 1 && fabs(CGRectGetMinX(frame)) <= 1
&& fabs(CGRectGetWidth(frame) - screenSize.width) <= 1
&& CGRectGetHeight(frame) > 0 && CGRectGetHeight(frame) < screenSize.height
&& CGRectGetHeight(frame) < CGRectGetWidth(frame)) {
statusBarSize = frame.size;
break;
}
}

return FBResponseWithObject(
@{
@"screenSize":@{@"width": @(screenSize.width),
Expand All @@ -181,8 +206,8 @@ + (NSArray *)routes
@"statusBarSize": @{@"width": @(statusBarSize.width),
@"height": @(statusBarSize.height),
},
@"displayId": @([FBScreen displayID]),
@"scale": @([FBScreen scale]),
@"displayId": @(screen.displayID),
@"scale": @(screen.scale),
});
}

Expand Down
135 changes: 135 additions & 0 deletions WebDriverAgentTests/IntegrationTests/FBScreenTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,23 @@
#import "FBIntegrationTestCase.h"
#import "FBConfiguration.h"
#import "FBScreen.h"
#import "FBRunLoopSpinner.h"
#import "XCUIDevice+FBRotation.h"
#import "XCUIDevice+FBHinge.h"
#import "XCUIElement.h"
#import "FBActiveAppDetectionPoint.h"
#import "FBExceptions.h"
#import "FBScreenRecordingRequest.h"
#import "FBScreenshot.h"
#import "XCUIScreen.h"
#import "FBCustomCommands.h"
#import "FBRouteRequest.h"
#import "FBResponsePayload.h"
#import "RouteResponse.h"

@interface FBCustomCommands (FBScreenInfoTesting)
+ (id<FBResponsePayload>)handleGetScreen:(FBRouteRequest *)request;
@end

@interface FBScreen (FBLookupTesting)
+ (nullable XCUIScreen *)screenWithDisplayID:(long long)displayID device:(id)device error:(NSError **)error;
Expand Down Expand Up @@ -299,4 +311,127 @@ - (void)testScreenScale
XCTAssertTrue([FBScreen scale] >= 2);
}

- (NSDictionary *)screenInfoResponse
{
FBRouteRequest *request = [FBRouteRequest routeRequestWithURL:[NSURL URLWithString:@"http://localhost:8100/wda/screen"]
parameters:@{}
arguments:@{}];
RouteResponse *response = [RouteResponse new];
[[FBCustomCommands handleGetScreen:request] dispatchWithResponse:response];
return [NSJSONSerialization JSONObjectWithData:response.responseData options:0 error:nil][@"value"];
}

- (void)testScreenInfoFollowsDisplaySelectionAndReset
{
for (NSDictionary *screen in [FBScreen screensWithError:nil]) {
FBConfiguration.sharedInstance.currentDisplayId = screen[@"displayId"];
NSDictionary *info = [self screenInfoResponse];
XCTAssertNil(info[@"error"]);
XCTAssertEqualObjects(info[@"displayId"], screen[@"displayId"]);
XCTAssertEqualObjects(info[@"scale"], screen[@"scale"]);
double width = [info[@"screenSize"][@"width"] doubleValue];
double height = [info[@"screenSize"][@"height"] doubleValue];
double scale = [screen[@"scale"] doubleValue];
double expectedWidth = [screen[@"bounds"][@"width"] doubleValue] / scale;
double expectedHeight = [screen[@"bounds"][@"height"] doubleValue] / scale;
XCTAssertEqualWithAccuracy(MIN(width, height), MIN(expectedWidth, expectedHeight), 0.01);
XCTAssertEqualWithAccuracy(MAX(width, height), MAX(expectedWidth, expectedHeight), 0.01);
double barHeight = [info[@"statusBarSize"][@"height"] doubleValue];
XCTAssertGreaterThanOrEqual(barHeight, 0);
XCTAssertLessThan(barHeight, height);
if (barHeight > 0) {
XCTAssertEqualWithAccuracy([info[@"statusBarSize"][@"width"] doubleValue], width, 1);
}
}
FBConfiguration.sharedInstance.currentDisplayId = nil;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will never be executed if any of previous steps fails

XCTAssertEqualObjects([self screenInfoResponse][@"displayId"], @([FBScreen displayID]));
}

- (void)testScreenInfoRejectsUnavailableDisplay
{
FBConfiguration.sharedInstance.currentDisplayId = @([self unknownDisplayID]);
NSDictionary *info = [self screenInfoResponse];
XCTAssertEqualObjects(info[@"error"], @"invalid argument");
XCTAssertTrue([info[@"message"] containsString:@"Available display ids"]);
}

- (void)testScreenInfoInBothLandscapeOrientations
{
XCTSkipIf(XCUIDevice.sharedDevice.fb_canAttemptSimulatedHingeAngleInjection,
@"Foldable orientations are covered by testScreenInfoAcrossFoldStates");
XCUIElement *window = self.testedApplication.windows.firstMatch;
(void)window.frame;
XCTSkipIf(window.screen.displayID != XCUIScreen.mainScreen.displayID,
@"Requires the fixture on the main display");
[self addTeardownBlock:^{
[[XCUIDevice sharedDevice] fb_setDeviceInterfaceOrientation:UIDeviceOrientationPortrait];
}];
BOOL expectsVisibleBar = UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad;
for (NSNumber *orientation in @[@(UIDeviceOrientationLandscapeLeft), @(UIDeviceOrientationLandscapeRight)]) {
XCTAssertTrue([[XCUIDevice sharedDevice] fb_setDeviceInterfaceOrientation:orientation.integerValue]);
__block NSDictionary *info = nil;
// iPad keeps its top status bar in landscape. Wait for rotation to finish
// rather than accepting a transient zero crop as a successful result.
XCTAssertTrue([[[FBRunLoopSpinner new] timeout:5] spinUntilTrue:^BOOL {
info = [self screenInfoResponse];
double width = [info[@"screenSize"][@"width"] doubleValue];
double height = [info[@"screenSize"][@"height"] doubleValue];
double barWidth = [info[@"statusBarSize"][@"width"] doubleValue];
double barHeight = [info[@"statusBarSize"][@"height"] doubleValue];
if (nil != info[@"error"] || width <= height || height <= 0) {
return NO;
}
if (barHeight == 0) {
return !expectsVisibleBar && barWidth == 0;
}
return barHeight > 0 && barHeight < height && fabs(barWidth - width) <= 1;
}], @"Unexpected landscape screen metadata: %@", info);
}
}

- (void)testScreenInfoAcrossFoldStates
{
XCUIDevice *device = XCUIDevice.sharedDevice;
XCTSkipIf(!device.fb_canAttemptSimulatedHingeAngleInjection, @"Requires a foldable simulator");
NSError *error = nil;
NSNumber *originalAngle = [device fb_getSimulatedHingeAngle:&error];
XCTAssertNotNil(originalAngle, @"%@", error);
[self addTeardownBlock:^{
if (nil != originalAngle) {
[device fb_setSimulatedHingeAngle:originalAngle.doubleValue error:nil];
}
}];
for (NSNumber *angle in @[@0, @90, @180, @0]) {
[self.testedApplication terminate];
XCTAssertTrue([device fb_setSimulatedHingeAngle:angle.doubleValue error:&error], @"%@", error);
[self launchApplication];
XCUIElement *window = self.testedApplication.windows.firstMatch;
CGRect frame = window.frame;
FBConfiguration.sharedInstance.currentDisplayId = @(window.screen.displayID);
__block NSDictionary *info = nil;
XCTAssertTrue([[[FBRunLoopSpinner new] timeout:5] spinUntilTrue:^BOOL {
info = [self screenInfoResponse];
double width = [info[@"screenSize"][@"width"] doubleValue];
double height = [info[@"screenSize"][@"height"] doubleValue];
double barHeight = [info[@"statusBarSize"][@"height"] doubleValue];
return nil == info[@"error"] && fabs(width - frame.size.width) <= 1
&& fabs(height - frame.size.height) <= 1
&& barHeight >= 0 && barHeight < height
&& (barHeight == 0 || fabs([info[@"statusBarSize"][@"width"] doubleValue] - width) <= 1)
&& (angle.doubleValue == 0 || barHeight == 0);
}], @"Unexpected screen metadata at hinge angle %@: %@", angle, info);
}
}

- (void)testScreenInfoIncludesTopStatusBarOnSingleDisplay
{
XCTSkipIf([FBScreen screensWithError:nil].count != 1, @"Requires a single-display device");
// The integration fixture uses the ordinary visible portrait status bar.
// An unresolved XCUIElement has displayID == 0; it must not be filtered out.
NSDictionary *info = [self screenInfoResponse];
XCTAssertGreaterThan([info[@"statusBarSize"][@"height"] doubleValue], 0);
XCTAssertEqualWithAccuracy([info[@"statusBarSize"][@"width"] doubleValue],
[info[@"screenSize"][@"width"] doubleValue], 1);
}

@end
8 changes: 8 additions & 0 deletions docs/multiple-displays.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ pauses frame delivery until a valid display is selected. Set `currentDisplayId`
to `null` to restore the main display. WDA does not automatically switch this
setting when the device changes pose.

`GET /wda/screen` also follows `currentDisplayId`: `displayId` and `scale`
belong to the selected screen, and `screenSize` is its size in logical pixels,
adjusted to the active application's orientation. Unavailable selections return
an error. The status bar must belong to that display and be visible at its top.
Hidden containers and side-mounted status UI do not define a top-edge crop;
`statusBarSize` is zero when no such bar is present. Clients should refresh this
information after display, orientation, or status-bar changes.

The XCTest point lookup used by WDA has no display argument, including in the
Xcode 27.1 interfaces checked for this change. When a secondary display is
selected, WDA uses its active-app fallback instead of that main-display lookup.
Expand Down
Loading