Skip to content

Commit

Permalink
feat: Add support of deep links (#296)
Browse files Browse the repository at this point in the history
  • Loading branch information
mykola-mokhnach committed Apr 19, 2024
1 parent f0ba9e2 commit 4076190
Show file tree
Hide file tree
Showing 11 changed files with 351 additions and 35 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,18 @@ A list of dictionaries where each item has the following keys:
- `isMain`: Whether this display is the main one
- `payload`: The actual PNG screenshot data encoded to base64 string

### mobile: deepLink

Opens the given URL with the default or the given application.
Xcode must be at version 14.3+.

#### Arguments

Name | Type | Required | Description | Example
--- | --- | --- | --- | ---
url | string | yes | The URL to be opened. This parameter is manadatory. | https://apple.com, myscheme:yolo
bundleId | string | no | The bundle identifier of an application to open the given url with. If not provided then the default application for the given url scheme is going to be used. | com.myapp.yolo


## Application Under Test Concept

Expand Down
40 changes: 40 additions & 0 deletions WebDriverAgentMac/IntegrationTests/AMDeviceTests.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#import <XCTest/XCTest.h>

#import "AMIntegrationTestCase.h"
#import "AMXCUIDeviceWrapper.h"


@interface AMDeviceTests : AMIntegrationTestCase
@end

@implementation AMDeviceTests

- (void)testDeepLinkCouldBeOpened
{
if (!AMXCUIDeviceWrapper.sharedDevice.supportsOpenUrl) {
return;
}

NSError *error;
XCTAssertTrue([AMXCUIDeviceWrapper.sharedDevice openUrl:[NSURL URLWithString:@"https://apple.com"]
error:&error]);
XCTAssertNil(error);
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@

#import "XCUIApplication+FBW3CActions.h"

#import "AMXCUIDeviceWrapper.h"
#import "FBBaseActionsSynthesizer.h"
#import "FBErrorBuilder.h"
#import "FBW3CActionsSynthesizer.h"
#import "XCUIEventSynthesizing-Protocol.h"

#define MAX_ACTIONS_DURATION_SEC 300

Expand All @@ -30,36 +30,7 @@ - (BOOL)fb_performW3CActions:(NSArray *)actions
return NO;
}
XCSynthesizedEventRecord *eventRecord = [synthesizer synthesizeWithError:error];
return nil == eventRecord ? NO : [self fb_synthesizeEvent:eventRecord error:error];
}

- (BOOL)fb_synthesizeEvent:(XCSynthesizedEventRecord *)event error:(NSError *__autoreleasing*)error
{
__block NSError *internalError = nil;
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
id<XCUIEventSynthesizing> eventSynthesizer = [[NSClassFromString(@"XCUIDevice") valueForKey:@"sharedDevice"]
valueForKey:@"eventSynthesizer"];
[eventSynthesizer synthesizeEvent:event
completion:(id)^(BOOL result, NSError *invokeError) {
if (!result) {
internalError = invokeError;
}
dispatch_semaphore_signal(sem);
}];
BOOL didTimeout = 0 != dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(MAX_ACTIONS_DURATION_SEC * NSEC_PER_SEC)));
if (didTimeout) {
return [[[FBErrorBuilder builder]
withDescriptionFormat:@"Cannot perform actions within %@ seconds timeout", @(MAX_ACTIONS_DURATION_SEC)]
buildError:error];;
}
if (nil != internalError) {
if (error) {
*error = internalError;
}
return NO;
}

return YES;
return nil == eventRecord ? NO : [AMXCUIDeviceWrapper.sharedDevice synthesizeEvent:eventRecord error:error];
}

@end
22 changes: 18 additions & 4 deletions WebDriverAgentMac/WebDriverAgentLib/Commands/FBSessionCommands.m
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

#import "AMSessionCapabilities.h"
#import "AMSettings.h"
#import "AMXCUIDeviceWrapper.h"
#import "FBConfiguration.h"
#import "FBLogger.h"
#import "FBProtocolHelpers.h"
Expand Down Expand Up @@ -52,16 +53,29 @@ + (NSArray *)routes
+ (id<FBResponsePayload>)handleOpenURL:(FBRouteRequest *)request
{
NSString *urlString = request.arguments[@"url"];
NSString *bundleId = request.arguments[@"bundleId"];
NSURL *url = [NSURL URLWithString:urlString];
if (!url) {
NSString *message = [NSString stringWithFormat:@"'%@' should be a valid URL", urlString];
return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:message
traceback:nil]);
}
if (![[NSWorkspace sharedWorkspace] openURL:url]) {
NSString *message = [NSString stringWithFormat:@"'%@' cannot be opened", urlString];
return FBResponseWithStatus([FBCommandStatus unknownErrorWithMessage:message
traceback:nil]);
if ([AMXCUIDeviceWrapper.sharedDevice supportsOpenUrl] || nil != bundleId) {
NSError *error;
BOOL result = nil == bundleId
? [AMXCUIDeviceWrapper.sharedDevice openUrl:url error:&error]
: [AMXCUIDeviceWrapper.sharedDevice openUrl:url withApplication:bundleId error:&error];
if (!result) {
NSString *message = [NSString stringWithFormat:@"'%@' cannot be opened: %@", urlString, error.localizedDescription];
return FBResponseWithStatus([FBCommandStatus unknownErrorWithMessage:message
traceback:nil]);
}
} else {
if (![[NSWorkspace sharedWorkspace] openURL:url]) {
NSString *message = [NSString stringWithFormat:@"'%@' cannot be opened", urlString];
return FBResponseWithStatus([FBCommandStatus unknownErrorWithMessage:message
traceback:nil]);
}
}
return FBResponseWithOK();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// Generated by class-dump 3.5 (64 bit).
//
// class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.
//

@class NSString, NSURL;

@protocol XCUIApplicationProcessManaging
- (void)openURL:(NSURL *)arg1 usingApplication:(NSString *)arg2 completion:(void (^)(_Bool, NSError *))arg3;
- (void)openDefaultApplicationForURL:(NSURL *)arg1 completion:(void (^)(_Bool, NSError *))arg2;
@end
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#import <XCTest/XCTest.h>
#import "XCUIApplicationProcessManaging-Protocol.h"

@class XCSynthesizedEventRecord;

NS_ASSUME_NONNULL_BEGIN

@interface AMXCUIDeviceWrapper : NSObject

/**
This is a wrapper for XCUIDevice.sharedDevice API,
which was only made public for macOS since Xcode 13
*/
+ (instancetype)sharedDevice;

/**
Whether the current Xcode SDK supports opening of URLs
*/
- (BOOL)supportsOpenUrl;

/**
Opens the particular url scheme using the default application assigned to it.
This API only works since XCode 14.3
@param url The url scheme represented as a string, for example https://apple.com
@param error If there is an error, upon return contains an NSError object that describes the problem.
@return YES if the operation was successful
*/
- (BOOL)openUrl:(NSURL *)url error:(NSError **)error;

/**
Opens the particular url scheme using the given application
This API only works since XCode 14.3
@param url The url scheme represented as a string, for example https://apple.com
@param bundleId The bundle identifier of an application to use in order to open the given URL
@param error If there is an error, upon return contains an NSError object that describes the problem.
@return YES if the operation was successful
*/
- (BOOL)openUrl:(NSURL *)url
withApplication:(NSString *)bundleId
error:(NSError **)error;

/**
Synthesizes an input event according to the given event spec
@param event The event spec
@param error If there is an error, upon return contains an NSError object that describes the problem.
@return YES if the operation was successful
*/
- (BOOL)synthesizeEvent:(XCSynthesizedEventRecord *)event
error:(NSError **)error;

@end

NS_ASSUME_NONNULL_END
152 changes: 152 additions & 0 deletions WebDriverAgentMac/WebDriverAgentLib/Utilities/AMXCUIDeviceWrapper.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#import "AMXCUIDeviceWrapper.h"

#import "FBErrorBuilder.h"
#import "FBRunLoopSpinner.h"
#import "XCSynthesizedEventRecord.h"
#import "XCUIEventSynthesizing-Protocol.h"

#define MAX_ACTIONS_DURATION_SEC 300

@implementation AMXCUIDeviceWrapper

+ (instancetype)sharedDevice;
{
static AMXCUIDeviceWrapper *instance;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}

- (id)xcuiDeviceInstance
{
return [NSClassFromString(@"XCUIDevice") valueForKey:@"sharedDevice"];
}

- (BOOL)supportsOpenUrl
{
id<XCUIApplicationProcessManaging> platformApplicationManager = [self am_platformApplicationManager];
return nil != platformApplicationManager
&& [(NSObject *)platformApplicationManager respondsToSelector:@selector(openDefaultApplicationForURL:completion:)];
}

- (id<XCUIApplicationProcessManaging>)am_platformApplicationManager
{
return [self.xcuiDeviceInstance valueForKey:@"platformApplicationManager"];
}

- (id<XCUIEventSynthesizing>)am_eventSynthesizer
{
return [self.xcuiDeviceInstance valueForKey:@"eventSynthesizer"];
}

- (BOOL)openUrl:(NSURL *)url error:(NSError **)error
{
id<XCUIApplicationProcessManaging> platformApplicationManager = [self am_platformApplicationManager];
if (nil == platformApplicationManager
|| ![(NSObject *)platformApplicationManager respondsToSelector:@selector(openDefaultApplicationForURL:completion:)]) {
NSString *description = [NSString stringWithFormat:@"Cannot open '%@' with the default application assigned for it. Consider upgrading to Xcode 14.3+", url];
return [[[FBErrorBuilder builder]
withDescriptionFormat:@"%@", description]
buildError:error];;
}

__block NSError *innerError = nil;
__block BOOL didSucceed = NO;
[FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){
[platformApplicationManager openDefaultApplicationForURL:url
completion:^(bool result, NSError *invokeError) {
if (nil != invokeError) {
innerError = invokeError;
} else {
didSucceed = result;
}
completion();
}];
}];
if (nil != innerError && error) {
*error = innerError;
}
return didSucceed;
}

- (BOOL)openUrl:(NSURL *)url
withApplication:(NSString *)bundleId
error:(NSError **)error
{
id<XCUIApplicationProcessManaging> platformApplicationManager = [self am_platformApplicationManager];
if (nil == platformApplicationManager
|| ![(NSObject *)platformApplicationManager respondsToSelector:@selector(openURL:usingApplication:completion:)]) {
NSString *description = [NSString stringWithFormat:@"Cannot open '%@' with the default application assigned for it. Consider upgrading to Xcode 14.3+", url];
return [[[FBErrorBuilder builder]
withDescriptionFormat:@"%@", description]
buildError:error];;
}

__block NSError *innerError = nil;
__block BOOL didSucceed = NO;
[FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){
[platformApplicationManager openURL:(NSURL *)url
usingApplication:bundleId
completion:^(bool result, NSError *invokeError) {
if (nil != invokeError) {
innerError = invokeError;
} else {
didSucceed = result;
}
completion();
}];
}];
if (nil != innerError && error) {
*error = innerError;
}
return didSucceed;
}

- (BOOL)synthesizeEvent:(XCSynthesizedEventRecord *)event
error:(NSError **)error
{
__block NSError *internalError = nil;
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
id<XCUIEventSynthesizing> eventSynthesizer = [self am_eventSynthesizer];
[eventSynthesizer synthesizeEvent:event
completion:(id)^(BOOL result, NSError *invokeError) {
if (!result) {
internalError = invokeError;
}
dispatch_semaphore_signal(sem);
}];
BOOL didTimeout = 0 != dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(MAX_ACTIONS_DURATION_SEC * NSEC_PER_SEC)));
if (didTimeout) {
return [[[FBErrorBuilder builder]
withDescriptionFormat:@"Cannot perform actions within %@ seconds timeout", @(MAX_ACTIONS_DURATION_SEC)]
buildError:error];;
}
if (nil != internalError) {
if (error) {
*error = internalError;
}
return NO;
}

return YES;
}

@end
Loading

0 comments on commit 4076190

Please sign in to comment.