diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 950cea2..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": ["@appium/eslint-config-appium-ts"], - "overrides": [ - { - "files": "test/**/*.js", - "rules": { - "func-names": "off", - "@typescript-eslint/no-var-requires": "off" - } - }, - { - "files": "scripts/**/*", - "parserOptions": {"sourceType": "script"}, - "rules": { - "@typescript-eslint/no-var-requires": "off" - } - } - ], - "rules": { - "require-await": "error" - } -} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..4fef437 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +save-exact=true +package-lock=false diff --git a/README.md b/README.md index dc9c118..b012ffd 100644 --- a/README.md +++ b/README.md @@ -586,7 +586,7 @@ Retrieves a screenshot of each display available to macOS. Name | Type | Required | Description | Example --- | --- | --- | --- | --- -displayId | number | no | Display identifier to take a screenshot for. If not provided then all display screenshots are going to be returned. If no matches were found then an error is thrown. | 1 +displayId | number | no | Display identifier to take a screenshot for. If not provided then all display screenshots are going to be returned. If no matches were found then an error is thrown. Use the `system_profiler -json SPDisplaysDataType` Terminal command to list IDs of connected displays or the [macos: listDisplays](#macos-listdisplays) API. | 1 #### Returns @@ -595,7 +595,7 @@ 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 +### macos: deepLink Opens the given URL with the default or the given application. Xcode must be at version 14.3+. @@ -607,6 +607,85 @@ 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 +### macos: startNativeScreenRecording + +Initiates a new native screen recording session via XCTest. +If the screen recording is already running then this call results in noop. +A screen recording is running until a testing session is finished. +If a recording has never been stopped explicitly during a test session +then it would be stopped automatically upon the test session termination, +and leftover videos would be deleted as well. +Xcode must be at version 15+. + +#### Arguments + +Name | Type | Required | Description | Example +--- | --- | --- | --- | --- +fps | number | no | Frame Per Second setting for the resulting screen recording. 24 by default. Higher FPS values may significantly increase the size of the resulting video. | 60 +codec | number | no | Possible codec value, where `0` means H264 (the default setting), `1` means HEVC | 1 +displayId | number | no | Valid display identifier to record the video from. Main display ID is assumed by default. Use the `system_profiler -json SPDisplaysDataType` Terminal command to list IDs of connected displays or the [macos: listDisplays](#macos-listdisplays) API. | 1 + +#### Returns + +The information about the asynchronously running video recording, which includes the following items: + +Name | Type | Description | Example +--- | --- | --- | --- +fps | number | Frame Per Second value | 24 +codec | number | Codec value, where `0` means H264 (the default setting), `1` means HEVC | 1 +displayId | number | Display identifier used to record this video for. | 1 +uuid | string | Unique video identifier. It is also used by XCTest to store the video on the file system. Look for `$HOME/Library/Daemon Containers//Data/Attachments/` to find the appropriate video file. Add the `.mp4` extension to it to make it openable by video players. +startedAt | number | Unix timestamp of the video startup moment | 123456789 + +### macos: getNativeScreenRecordingInfo + +Fetches the information of the currently running native video recording. +Xcode must be at version 15+. + +#### Returns + +Either `null` if no native video recording is currently active or the same map that [macos: startNativeScreenRecording](#macos-startnativescreenrecording) returns. + +### macos: stopNativeScreenRecording + +Stops native screen recording previously started by +[macos: startNativeScreenRecording](#macos-startnativescreenrecording) +and returns the video payload or uploads it to a remote location, +depending on the provided arguments. +The actual video file is removed from the local file system after the video payload is +successfully consumed. +If no screen recording has been started before then this API throws an exception. +Xcode must be at version 15+. + +#### Arguments + +Name | Type | Required | Description | Example +--- | --- | --- | --- | --- +remotePath | string | no | The path to the remote location, where the resulting video should be uploaded. The following protocols are supported: http/https, ftp. Null or empty string value (the default setting) means the content of resulting file should be encoded as Base64 and passed as the endpoint response value. An exception will be thrown if the generated media file is too big to fit into the available process memory. | https://myserver.com/upload/video.mp4 +user | string | no | The name of the user for the remote authentication. | myname +pass | string | no | The password for the remote authentication. | mypassword +method | string | no | The http multipart upload method name. The 'PUT' one is used by default. | POST +headers | map | no | Additional headers mapping for multipart http(s) uploads | `{"header": "value"}` +fileFieldName | string | no | The name of the form field, where the file content BLOB should be stored for http(s) uploads. `file` by default | payload +formFields | Map or `Array` | no | Additional form fields for multipart http(s) uploads | `{"field1": "value1", "field2": "value2"}` or `[["field1", "value1"], ["field2", "value2"]]` + +#### Returns + +Base64-encoded content of the recorded media file if `remotePath` parameter is falsy or an empty string. + +### macos: listDisplays + +Fetches information about available displays. + +#### Returns + +A map where keys are display identifiers represented as strings and values are display infos containing the following items: + +Name | Type | Description | Example +--- | --- | --- | --- +id | number | Display identifier | 12345 +isMain | boolean | Is `true` if the display is configured as a main system display | false + ## Application Under Test Concept diff --git a/WebDriverAgentMac/IntegrationTests/AMVideoRecordingTests.m b/WebDriverAgentMac/IntegrationTests/AMVideoRecordingTests.m new file mode 100644 index 0000000..e4b4516 --- /dev/null +++ b/WebDriverAgentMac/IntegrationTests/AMVideoRecordingTests.m @@ -0,0 +1,57 @@ +/* + * 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 + +#import "AMIntegrationTestCase.h" +#import "AMVideoRecorder.h" +#import "FBScreenRecordingRequest.h" +#import "FBTestMacros.h" +#import "FBScreenRecordingContainer.h" +#import "FBScreenRecordingPromise.h" + + +@interface AMVideoRecordingTests : AMIntegrationTestCase +@end + +@implementation AMVideoRecordingTests + +- (void)setUp +{ + [super setUp]; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + [self launchApplication]; + }); +} + +- (void)testVideoRecording +{ + AMVideoRecorder *recorder = AMVideoRecorder.sharedInstance; + FBScreenRecordingRequest *request = [[FBScreenRecordingRequest alloc] initWithFps:24 + codec:0 + displayID:nil]; + NSError *error; + FBScreenRecordingPromise *promise = [recorder startScreenRecordingWithRequest:request + error:&error]; + XCTAssertNotNil(promise); + XCTAssertNil(error); + FBWaitExact(5); + XCTAssertTrue([recorder stopScreenRecordingWithUUID:promise.identifier error:&error]); + XCTAssertNil(error); +} + +@end diff --git a/WebDriverAgentMac/WebDriverAgentLib/Commands/AMVideoCommands.h b/WebDriverAgentMac/WebDriverAgentLib/Commands/AMVideoCommands.h new file mode 100644 index 0000000..e1645eb --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Commands/AMVideoCommands.h @@ -0,0 +1,27 @@ +/* + * 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 + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface AMVideoCommands : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentMac/WebDriverAgentLib/Commands/AMVideoCommands.m b/WebDriverAgentMac/WebDriverAgentLib/Commands/AMVideoCommands.m new file mode 100644 index 0000000..f9065f5 --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Commands/AMVideoCommands.m @@ -0,0 +1,120 @@ +/* + * 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 "AMVideoCommands.h" + +#import "FBRouteRequest.h" +#import "FBScreenRecordingContainer.h" +#import "FBScreenRecordingPromise.h" +#import "FBScreenRecordingRequest.h" +#import "AMScreenUtils.h" +#import "FBSession.h" +#import "AMVideoRecorder.h" +#import "FBErrorBuilder.h" + +const NSUInteger DEFAULT_FPS = 24; +const NSUInteger DEFAULT_CODEC = 0; + +@implementation AMVideoCommands + ++ (NSArray *)routes +{ + return + @[ + [[FBRoute POST:@"/wda/video/start"] respondWithTarget:self action:@selector(handleStartVideoRecording:)], + [[FBRoute POST:@"/wda/video/stop"] respondWithTarget:self action:@selector(handleStopVideoRecording:)], + [[FBRoute GET:@"/wda/video"] respondWithTarget:self action:@selector(handleGetVideoRecording:)], + + [[FBRoute POST:@"/wda/video/start"].withoutSession respondWithTarget:self action:@selector(handleStartVideoRecording:)], + [[FBRoute POST:@"/wda/video/stop"].withoutSession respondWithTarget:self action:@selector(handleStopVideoRecording:)], + [[FBRoute GET:@"/wda/video"].withoutSession respondWithTarget:self action:@selector(handleGetVideoRecording:)], + ]; +} + ++ (id)handleStartVideoRecording:(FBRouteRequest *)request +{ + FBScreenRecordingPromise *activeScreenRecording = FBScreenRecordingContainer.sharedInstance.screenRecordingPromise; + if (nil != activeScreenRecording) { + return FBResponseWithObject([FBScreenRecordingContainer.sharedInstance toDictionary] ?: [NSNull null]); + } + + NSNumber *fps = (NSNumber *)request.arguments[@"fps"] ?: @(DEFAULT_FPS); + NSNumber *codec = (NSNumber *)request.arguments[@"codec"] ?: @(DEFAULT_CODEC); + NSNumber *displayID = (NSNumber *)request.arguments[@"displayId"]; + if (nil != displayID) { + NSError *error; + if (![self verifyDisplayWithID:displayID.longLongValue error:&error]) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:error.description + traceback:nil]); + } + } + FBScreenRecordingRequest *recordingRequest = [[FBScreenRecordingRequest alloc] initWithFps:fps.integerValue + codec:codec.longLongValue + displayID:displayID]; + NSError *error; + FBScreenRecordingPromise* promise = [AMVideoRecorder.sharedInstance startScreenRecordingWithRequest:recordingRequest + error:&error]; + if (nil == promise) { + [FBScreenRecordingContainer.sharedInstance reset]; + return FBResponseWithUnknownError(error); + } + [FBScreenRecordingContainer.sharedInstance storeScreenRecordingPromise:promise + fps:fps.integerValue + codec:codec.longLongValue + displayID:displayID]; + return FBResponseWithObject([FBScreenRecordingContainer.sharedInstance toDictionary]); +} + ++ (id)handleStopVideoRecording:(FBRouteRequest *)request +{ + FBScreenRecordingPromise *activeScreenRecording = FBScreenRecordingContainer.sharedInstance.screenRecordingPromise; + if (nil == activeScreenRecording) { + return FBResponseWithOK(); + } + + NSUUID *recordingId = activeScreenRecording.identifier; + NSDictionary *response = [FBScreenRecordingContainer.sharedInstance toDictionary]; + NSError *error; + if (![AMVideoRecorder.sharedInstance stopScreenRecordingWithUUID:recordingId error:&error]) { + [FBScreenRecordingContainer.sharedInstance reset]; + return FBResponseWithUnknownError(error); + } + [FBScreenRecordingContainer.sharedInstance reset]; + return FBResponseWithObject(response); +} + ++ (id)handleGetVideoRecording:(FBRouteRequest *)request +{ + return FBResponseWithObject([FBScreenRecordingContainer.sharedInstance toDictionary] ?: [NSNull null]); +} + ++ (BOOL)verifyDisplayWithID:(long long)displayID error:(NSError **)error +{ + NSMutableArray* availableIds = [NSMutableArray array]; + for (XCUIScreen *screen in XCUIScreen.screens) { + long long currentDisplayId = AMFetchScreenId(screen); + if (displayID == currentDisplayId) { + return YES; + } + [availableIds addObject:@(currentDisplayId)]; + } + return [[[FBErrorBuilder builder] + withDescriptionFormat:@"The provided display identifier %lld is not known. Only the following values are allowed: %@", + displayID, [availableIds componentsJoinedByString:@","]] + buildError:error]; +} + +@end diff --git a/WebDriverAgentMac/WebDriverAgentLib/Commands/FBDebugCommands.m b/WebDriverAgentMac/WebDriverAgentLib/Commands/FBDebugCommands.m index 7458526..8c1776f 100644 --- a/WebDriverAgentMac/WebDriverAgentLib/Commands/FBDebugCommands.m +++ b/WebDriverAgentMac/WebDriverAgentLib/Commands/FBDebugCommands.m @@ -9,6 +9,7 @@ #import "FBDebugCommands.h" +#import "AMScreenUtils.h" #import "FBRouteRequest.h" #import "FBSession.h" #import "XCUIApplication+AMSource.h" @@ -23,6 +24,9 @@ + (NSArray *)routes @[ [[FBRoute GET:@"/source"] respondWithTarget:self action:@selector(handleGetSourceCommand:)], [[FBRoute GET:@"/source"].withoutSession respondWithTarget:self action:@selector(handleGetSourceCommand:)], + + [[FBRoute GET:@"/wda/displays/list"] respondWithTarget:self action:@selector(handleListDisplays:)], + [[FBRoute GET:@"/wda/displays/list"].withoutSession respondWithTarget:self action:@selector(handleListDisplays:)], ]; } @@ -53,4 +57,17 @@ + (NSArray *)routes return FBResponseWithObject(result); } ++ (id)handleListDisplays:(FBRouteRequest *)request +{ + NSArray *screenInfos = AMListScreens(); + NSMutableDictionary *> *result = [NSMutableDictionary new]; + for (AMScreenProperties *screenInfo in screenInfos) { + result[[NSString stringWithFormat:@"%lld", screenInfo.identifier]] = @{ + @"id": @(screenInfo.identifier), + @"isMain": @(screenInfo.isMain), + }; + } + return FBResponseWithObject(result.copy); +} + @end diff --git a/WebDriverAgentMac/WebDriverAgentLib/Commands/FBScreenshotCommands.m b/WebDriverAgentMac/WebDriverAgentLib/Commands/FBScreenshotCommands.m index 5c5e2c5..6fc3f2a 100644 --- a/WebDriverAgentMac/WebDriverAgentLib/Commands/FBScreenshotCommands.m +++ b/WebDriverAgentMac/WebDriverAgentLib/Commands/FBScreenshotCommands.m @@ -9,7 +9,7 @@ #import "FBScreenshotCommands.h" -#import "XCTest/XCTest.h" +#import "AMScreenUtils.h" #import "FBRouteRequest.h" @implementation FBScreenshotCommands @@ -49,21 +49,21 @@ + (NSArray *)routes NSMutableDictionary *> *result = [NSMutableDictionary new]; NSMutableArray *availableDisplayIds = [NSMutableArray new]; for (XCUIScreen *screen in XCUIScreen.screens) { - NSNumber *displayId = [screen valueForKey:@"_displayID"]; - if (nil == displayId || (nil != desiredId && ![desiredId isEqualToNumber:displayId])) { + long long currentScreenId = AMFetchScreenId(screen); + if (nil != desiredId && desiredId.longLongValue != currentScreenId) { continue; } - [availableDisplayIds addObject:displayId]; - result[displayId.stringValue] = @{ - @"id": displayId, - @"isMain": [screen valueForKey:@"_isMainScreen"] ?: NSNull.null, + [availableDisplayIds addObject:@(currentScreenId)]; + result[[NSString stringWithFormat:@"%lld", currentScreenId]] = @{ + @"id": @(currentScreenId), + @"isMain": @(AMIsMainScreen(screen)), @"payload": [screen.screenshot.PNGRepresentation base64EncodedStringWithOptions:0] ?: NSNull.null }; } if (nil != desiredId && 0 == [result count]) { NSString *message = [NSString stringWithFormat:@"The screen identified by %@ is not available to XCTest. Only the following identifiers are available: %@", - desiredId, availableDisplayIds]; + desiredId, [availableDisplayIds componentsJoinedByString:@","]]; return FBResponseWithStatus([FBCommandStatus unableToCaptureScreenErrorWithMessage:message traceback:nil]); } diff --git a/WebDriverAgentMac/WebDriverAgentLib/Routing/AMXCTRunnerDaemonSession.h b/WebDriverAgentMac/WebDriverAgentLib/Routing/AMXCTRunnerDaemonSession.h new file mode 100644 index 0000000..f2aea53 --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Routing/AMXCTRunnerDaemonSession.h @@ -0,0 +1,31 @@ +/* + * 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 + +NS_ASSUME_NONNULL_BEGIN + +@protocol AMXCTRunnerDaemonSession + +- (void)stopScreenRecordingWithUUID:(NSUUID *)arg1 + withReply:(void (^)(NSError *))arg2; +- (void)startScreenRecordingWithRequest:(id/* XCTScreenRecordingRequest */)arg1 + withReply:(void (^)(id/* XCTAttachmentFutureMetadata */, NSError *))arg2; +- (_Bool)supportsScreenRecording; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentMac/WebDriverAgentLib/Routing/AMXCTRunnerDaemonSessionWrapper.h b/WebDriverAgentMac/WebDriverAgentLib/Routing/AMXCTRunnerDaemonSessionWrapper.h new file mode 100644 index 0000000..ee0e75b --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Routing/AMXCTRunnerDaemonSessionWrapper.h @@ -0,0 +1,39 @@ +/* + * 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 + +@protocol AMXCTRunnerDaemonSession; + +NS_ASSUME_NONNULL_BEGIN + +@interface AMXCTRunnerDaemonSessionWrapper : NSObject + ++ (instancetype)sharedInstance; + +/** + @return YES if the current Xcode SDK supports video recording + */ +- (BOOL)canRecordVideo; + +/** + @returns The internal XCTest daemon session instance + */ +- (id)daemonSession; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentMac/WebDriverAgentLib/Routing/AMXCTRunnerDaemonSessionWrapper.m b/WebDriverAgentMac/WebDriverAgentLib/Routing/AMXCTRunnerDaemonSessionWrapper.m new file mode 100644 index 0000000..807afee --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Routing/AMXCTRunnerDaemonSessionWrapper.m @@ -0,0 +1,43 @@ +/* + * 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 "AMXCTRunnerDaemonSessionWrapper.h" + +#import "AMXCTRunnerDaemonSession.h" + +@implementation AMXCTRunnerDaemonSessionWrapper + ++ (instancetype)sharedInstance +{ + static AMXCTRunnerDaemonSessionWrapper *instance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[self alloc] init]; + }); + return instance; +} + +- (id)daemonSession +{ + return (id)[NSClassFromString(@"XCTRunnerDaemonSession") sharedSession]; +} + +- (BOOL)canRecordVideo +{ + return [(NSObject *)self.daemonSession respondsToSelector:@selector(startScreenRecordingWithRequest:withReply:)]; +} + +@end diff --git a/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingContainer.h b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingContainer.h new file mode 100644 index 0000000..3b0ead8 --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingContainer.h @@ -0,0 +1,59 @@ +/** + * + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class FBScreenRecordingPromise; + +@interface FBScreenRecordingContainer : NSObject + +/** The amount of video FPS */ +@property (readonly, nonatomic) NSUInteger fps; +/** Codec to use, where 0 is h264, 1 - HEVC */ +@property (readonly, nonatomic) long long codec; +@property (readonly, nonatomic, nullable) NSNumber *displayID; +/** Keep the currently active screen resording promise. Equals to nil if no active screen recordings are running */ +@property (readonly, nonatomic, nullable) FBScreenRecordingPromise* screenRecordingPromise; +/** The timestamp of the video startup as Unix float seconds */ +@property (readonly, nonatomic, nullable) NSNumber *startedAt; + +/** +@return singleton instance + */ ++ (instancetype)sharedInstance; + +/** + Keeps current screen recording promise + + @param screenRecordingPromise a promise to set + @param fps FPS value + @param codec Codec value + */ +- (void)storeScreenRecordingPromise:(FBScreenRecordingPromise *)screenRecordingPromise + fps:(NSUInteger)fps + codec:(long long)codec + displayID:(nullable NSNumber *)displayID; +/** + Resets the current screen recording promise + */ +- (void)reset; + +/** + Transforms the container content to a dictionary. + + @return May return nil if no screen recording is currently running + */ +- (nullable NSDictionary *)toDictionary; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingContainer.m b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingContainer.m new file mode 100644 index 0000000..e61a427 --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingContainer.m @@ -0,0 +1,74 @@ +/** + * + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import "FBScreenRecordingContainer.h" + +#import "AMScreenUtils.h" +#import "FBScreenRecordingPromise.h" + +@interface FBScreenRecordingContainer () + +@property (readwrite) NSUInteger fps; +@property (readwrite) long long codec; +@property (readwrite) NSNumber *displayID; +@property (readwrite) FBScreenRecordingPromise* screenRecordingPromise; +@property (readwrite) NSNumber *startedAt; + +@end + +@implementation FBScreenRecordingContainer + ++ (instancetype)sharedInstance +{ + static FBScreenRecordingContainer *instance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[self alloc] init]; + }); + return instance; +} + +- (void)storeScreenRecordingPromise:(FBScreenRecordingPromise *)screenRecordingPromise + fps:(NSUInteger)fps + codec:(long long)codec + displayID:(nullable NSNumber *)displayID +{ + self.fps = fps; + self.codec = codec; + self.displayID = displayID; + self.screenRecordingPromise = screenRecordingPromise; + self.startedAt = @([NSDate.date timeIntervalSince1970]); +} + +- (void)reset; +{ + self.fps = 0; + self.codec = 0; + self.displayID = nil; + self.screenRecordingPromise = nil; + self.startedAt = nil; +} + +- (nullable NSDictionary *)toDictionary +{ + if (nil == self.screenRecordingPromise) { + return nil; + } + + return @{ + @"fps": @(self.fps), + @"codec": @(self.codec), + @"displayId": self.displayID ?: @(AMFetchScreenId(XCUIScreen.mainScreen)), + @"uuid": [self.screenRecordingPromise identifier].UUIDString ?: [NSNull null], + @"startedAt": self.startedAt ?: [NSNull null], + }; +} + +@end diff --git a/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingPromise.h b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingPromise.h new file mode 100644 index 0000000..6f21da0 --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingPromise.h @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FBScreenRecordingPromise : NSObject + +/** Unique identiifier of the video recording, also used as the default file name */ +@property (nonatomic, readonly) NSUUID *identifier; +/** Native screen recording promise */ +@property (nonatomic, readonly) id nativePromise; + +/** + Creates a wrapper object for a native screen recording promise. + The actual screen recording file is stored as + $HOME/Library/Daemon Containers/Data/Attachments/ + although this path is not is not accessible directly from within the XCTest container, + and must be accessed from elsewhere. + + @param promise Native promise object to be wrapped + */ +- (instancetype)initWithNativePromise:(id)promise; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingPromise.m b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingPromise.m new file mode 100644 index 0000000..9de9dba --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingPromise.m @@ -0,0 +1,32 @@ +/** + * + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import "FBScreenRecordingPromise.h" + +@interface FBScreenRecordingPromise () +@property (readwrite) id nativePromise; +@end + +@implementation FBScreenRecordingPromise + +- (instancetype)initWithNativePromise:(id)promise +{ + if ((self = [super init])) { + self.nativePromise = promise; + } + return self; +} + +- (NSUUID *)identifier +{ + return (NSUUID *)[self.nativePromise valueForKey:@"_UUID"]; +} + +@end diff --git a/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingRequest.h b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingRequest.h new file mode 100644 index 0000000..40c5e4f --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingRequest.h @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FBScreenRecordingRequest : NSObject + +/** The amount of video FPS */ +@property (readonly, nonatomic) NSUInteger fps; +/** Codec to use, where 0 is h264, 1 - HEVC */ +@property (readonly, nonatomic) long long codec; +@property (readonly, nonatomic, nullable) NSNumber *displayID; + +/** + Creates a custom wrapper for a screen recording reqeust + + @param fps FPS value, see baove + @param codec Codex value, see above + @param displayID Valid display identifier or nil to use the main display + */ +- (instancetype)initWithFps:(NSUInteger)fps + codec:(long long)codec + displayID:(nullable NSNumber *)displayID; + +/** + Transforms the current wrapper instance to a native object, + which is ready to be passed to XCTest APIs + + @param error If there was a failure converting the instance to a native object + @returns Native object instance + */ +- (nullable id)toNativeRequestWithError:(NSError **)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingRequest.m b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingRequest.m new file mode 100644 index 0000000..dab740c --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBScreenRecordingRequest.m @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import "FBScreenRecordingRequest.h" + +#import "FBErrorBuilder.h" + +@implementation FBScreenRecordingRequest + +- (instancetype)initWithFps:(NSUInteger)fps codec:(long long)codec displayID:(NSNumber *)displayID +{ + if ((self = [super init])) { + _fps = fps; + _codec = codec; + _displayID = displayID; + } + return self; +} + +- (nullable id)createVideoEncodingWithError:(NSError **)error +{ + Class videoEncodingClass = NSClassFromString(@"XCTVideoEncoding"); + if (nil == videoEncodingClass) { + [[[FBErrorBuilder builder] + withDescription:@"Cannot find XCTVideoEncoding class"] + buildError:error]; + return nil; + } + + id videoEncodingAllocated = [videoEncodingClass alloc]; + SEL videoEncodingConstructorSelector = NSSelectorFromString(@"initWithCodec:frameRate:"); + if (![videoEncodingAllocated respondsToSelector:videoEncodingConstructorSelector]) { + [[[FBErrorBuilder builder] + withDescription:@"'initWithCodec:frameRate:' contructor is not found on XCTVideoEncoding class"] + buildError:error]; + return nil; + } + + NSMethodSignature *videoEncodingContructorSignature = [videoEncodingAllocated methodSignatureForSelector:videoEncodingConstructorSelector]; + NSInvocation *videoEncodingInitInvocation = [NSInvocation invocationWithMethodSignature:videoEncodingContructorSignature]; + [videoEncodingInitInvocation setSelector:videoEncodingConstructorSelector]; + long long codec = self.codec; + [videoEncodingInitInvocation setArgument:&codec atIndex:2]; + double frameRate = self.fps; + [videoEncodingInitInvocation setArgument:&frameRate atIndex:3]; + [videoEncodingInitInvocation invokeWithTarget:videoEncodingAllocated]; + id __unsafe_unretained result; + [videoEncodingInitInvocation getReturnValue:&result]; + return result; +} + +- (id)toNativeRequestWithError:(NSError **)error +{ + Class screenRecordingRequestClass = NSClassFromString(@"XCTScreenRecordingRequest"); + if (nil == screenRecordingRequestClass) { + [[[FBErrorBuilder builder] + withDescription:@"Cannot find XCTScreenRecordingRequest class"] + buildError:error]; + return nil; + } + + id screenRecordingRequestAllocated = [screenRecordingRequestClass alloc]; + SEL screenRecordingRequestConstructorSelector = NSSelectorFromString(@"initWithScreenID:rect:preferredEncoding:"); + if (![screenRecordingRequestAllocated respondsToSelector:screenRecordingRequestConstructorSelector]) { + [[[FBErrorBuilder builder] + withDescription:@"'initWithScreenID:rect:preferredEncoding:' contructor is not found on XCTScreenRecordingRequest class"] + buildError:error]; + return nil; + } + id videoEncoding = [self createVideoEncodingWithError:error]; + if (nil == videoEncoding) { + return nil; + } + + NSMethodSignature *screenRecordingRequestContructorSignature = [screenRecordingRequestAllocated methodSignatureForSelector:screenRecordingRequestConstructorSelector]; + NSInvocation *screenRecordingRequestInitInvocation = [NSInvocation invocationWithMethodSignature:screenRecordingRequestContructorSignature]; + [screenRecordingRequestInitInvocation setSelector:screenRecordingRequestConstructorSelector]; + long long screenId = nil == self.displayID + ? [[XCUIScreen.mainScreen valueForKey:@"_displayID"] longLongValue] + : self.displayID.longLongValue; + [screenRecordingRequestInitInvocation setArgument:&screenId atIndex:2]; + CGRect fullScreenRect = CGRectNull; + [screenRecordingRequestInitInvocation setArgument:&fullScreenRect atIndex:3]; + [screenRecordingRequestInitInvocation setArgument:&videoEncoding atIndex:4]; + [screenRecordingRequestInitInvocation invokeWithTarget:screenRecordingRequestAllocated]; + id __unsafe_unretained result; + [screenRecordingRequestInitInvocation getReturnValue:&result]; + return result; +} + +@end diff --git a/WebDriverAgentMac/WebDriverAgentLib/Routing/FBSession.m b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBSession.m index 03377a0..72027db 100644 --- a/WebDriverAgentMac/WebDriverAgentLib/Routing/FBSession.m +++ b/WebDriverAgentMac/WebDriverAgentLib/Routing/FBSession.m @@ -17,6 +17,9 @@ #import "FBExceptions.h" #import "FBMacros.h" #import "XCUIApplication+AMHelpers.h" +#import "FBScreenRecordingContainer.h" +#import "FBScreenRecordingPromise.h" +#import "AMVideoRecorder.h" NSString *const FINDER_BUNDLE_ID = @"com.apple.finder"; @@ -69,6 +72,17 @@ - (void)kill [self.testedApplication terminate]; } self.testedApplication = nil; + FBScreenRecordingContainer *screenRecordingContainer = FBScreenRecordingContainer.sharedInstance; + NSUUID *videoRecordingId = screenRecordingContainer.screenRecordingPromise.identifier; + if (nil != videoRecordingId) { + NSError *error; + if (![AMVideoRecorder.sharedInstance stopScreenRecordingWithUUID:videoRecordingId error:&error]) { + NSLog(@"Could not stop the active video recording. Original error: %@", error.description); + } + } + if (nil != screenRecordingContainer.screenRecordingPromise) { + [screenRecordingContainer reset]; + } [self.elementCache reset]; _activeSession = nil; } diff --git a/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMScreenUtils.h b/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMScreenUtils.h new file mode 100644 index 0000000..e24e688 --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMScreenUtils.h @@ -0,0 +1,49 @@ +/* + * 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 + +NS_ASSUME_NONNULL_BEGIN + +@interface AMScreenProperties: NSObject + +/** YES if the corresponding screen is a main screen */ +@property (readonly, nonatomic) BOOL isMain; +/** The integer indentifier of a screen */ +@property (readonly, nonatomic) long long identifier; + +@end + +/** + Lists information about available screens + + @returns Screen infos + */ +NSArray *AMListScreens(void); + +/** + Retrieves identifies from a XCUIScreen instance + + @returns Screen identifier + */ +long long AMFetchScreenId(XCUIScreen *screen); + +/** + @returns YES if the given screen is the main one + */ +BOOL AMIsMainScreen(XCUIScreen *screen); + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMScreenUtils.m b/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMScreenUtils.m new file mode 100644 index 0000000..647194b --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMScreenUtils.m @@ -0,0 +1,49 @@ +/* + * 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 "AMScreenUtils.h" + +@implementation AMScreenProperties + +- (instancetype)initWithIdentifier:(long long)identifier + isMain:(BOOL)isMain +{ + if ((self = [super init])) { + _identifier = identifier; + _isMain = isMain; + } + return self; +} + +@end + +NSArray *AMListScreens(void) { + NSMutableArray *result = [NSMutableArray new]; + for (XCUIScreen *screen in XCUIScreen.screens) { + AMScreenProperties *info = [[AMScreenProperties alloc] initWithIdentifier:AMFetchScreenId(screen) + isMain:AMIsMainScreen(screen)]; + [result addObject:info]; + } + return result.copy; +} + +long long AMFetchScreenId(XCUIScreen *screen) { + return [[screen valueForKey:@"_displayID"] longLongValue]; +} + +BOOL AMIsMainScreen(XCUIScreen *screen) { + return [[screen valueForKey:@"_isMainScreen"] boolValue]; +} diff --git a/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMVideoRecorder.h b/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMVideoRecorder.h new file mode 100644 index 0000000..bba23be --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMVideoRecorder.h @@ -0,0 +1,50 @@ +/* + * 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 + +@class FBScreenRecordingPromise; +@class FBScreenRecordingRequest; + +NS_ASSUME_NONNULL_BEGIN + +@interface AMVideoRecorder : NSObject + ++ (instancetype)sharedInstance; + +/** + Starts native video recording + + @param request Video recording options + @param error Actual error instance if there was an error while starting the video recording + @returns The recording promise or nil in case of failure + */ +- (nullable FBScreenRecordingPromise *)startScreenRecordingWithRequest:(FBScreenRecordingRequest *)request + error:(NSError **)error; + +/** + Stops native video recording + + @param uuid The unique identifier of the recording process + @param error Actual error instance if there was an error while stopping the video recording + @returns YES if the recording has been successfully stopped + */ +- (BOOL)stopScreenRecordingWithUUID:(NSUUID *)uuid + error:(NSError **)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMVideoRecorder.m b/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMVideoRecorder.m new file mode 100644 index 0000000..fc7f7b6 --- /dev/null +++ b/WebDriverAgentMac/WebDriverAgentLib/Utilities/AMVideoRecorder.m @@ -0,0 +1,112 @@ +/* + * 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 "AMVideoRecorder.h" + +#import "FBErrorBuilder.h" +#import "FBScreenRecordingPromise.h" +#import "FBScreenRecordingRequest.h" +#import "FBRunLoopSpinner.h" +#import "AMXCTRunnerDaemonSessionWrapper.h" +#import "AMXCTRunnerDaemonSession.h" + + +@implementation AMVideoRecorder + ++ (instancetype)sharedInstance +{ + static AMVideoRecorder *instance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[self alloc] init]; + }); + return instance; +} + +- (FBScreenRecordingPromise *)startScreenRecordingWithRequest:(FBScreenRecordingRequest *)request + error:(NSError *__autoreleasing*)error +{ + AMXCTRunnerDaemonSessionWrapper *wrapper = AMXCTRunnerDaemonSessionWrapper.sharedInstance; + if (!wrapper.canRecordVideo) { + [[[FBErrorBuilder builder] + withDescriptionFormat:@"The current Xcode SDK does not support screen recording. Consider upgrading to Xcode 15+"] + buildError:error]; + return nil; + } + if (![wrapper.daemonSession supportsScreenRecording]) { + [[[FBErrorBuilder builder] + withDescriptionFormat:@"Your device does not support screen recording"] + buildError:error]; + return nil; + } + + id nativeRequest = [request toNativeRequestWithError:error]; + if (nil == nativeRequest) { + return nil; + } + + __block id futureMetadata = nil; + __block NSError *innerError = nil; + [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){ + [wrapper.daemonSession startScreenRecordingWithRequest:nativeRequest withReply:^(id reply, NSError *invokeError) { + if (nil == invokeError) { + futureMetadata = reply; + } else { + innerError = invokeError; + } + completion(); + }]; + }]; + if (nil != innerError) { + if (error) { + *error = innerError; + } + return nil; + } + return [[FBScreenRecordingPromise alloc] initWithNativePromise:futureMetadata]; +} + +- (BOOL)stopScreenRecordingWithUUID:(NSUUID *)uuid error:(NSError *__autoreleasing*)error +{ + AMXCTRunnerDaemonSessionWrapper *wrapper = AMXCTRunnerDaemonSessionWrapper.sharedInstance; + if (!wrapper.canRecordVideo) { + return [[[FBErrorBuilder builder] + withDescriptionFormat:@"The current Xcode SDK does not support screen recording. Consider upgrading to Xcode 15+"] + buildError:error]; + + } + if (![wrapper.daemonSession supportsScreenRecording]) { + return [[[FBErrorBuilder builder] + withDescriptionFormat:@"Your device does not support screen recording"] + buildError:error]; + } + + __block NSError *innerError = nil; + [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){ + [wrapper.daemonSession stopScreenRecordingWithUUID:uuid withReply:^(NSError *invokeError) { + if (nil != invokeError) { + innerError = invokeError; + } + completion(); + }]; + }]; + if (nil != innerError && error) { + *error = innerError; + } + return nil == innerError; +} + +@end diff --git a/WebDriverAgentMac/WebDriverAgentMac.xcodeproj/project.pbxproj b/WebDriverAgentMac/WebDriverAgentMac.xcodeproj/project.pbxproj index 8dbed0f..02bb352 100644 --- a/WebDriverAgentMac/WebDriverAgentMac.xcodeproj/project.pbxproj +++ b/WebDriverAgentMac/WebDriverAgentMac.xcodeproj/project.pbxproj @@ -119,6 +119,20 @@ 713A9D382566A83800118D07 /* FBElementCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = 713A9D362566A83800118D07 /* FBElementCommands.m */; }; 713A9D3C2566AA2300118D07 /* AMGeometryUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = 713A9D3A2566AA2300118D07 /* AMGeometryUtils.h */; }; 713A9D3D2566AA2300118D07 /* AMGeometryUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 713A9D3B2566AA2300118D07 /* AMGeometryUtils.m */; }; + 71440CC72D54AB460048EA32 /* AMVideoCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = 71440CC52D54AB460048EA32 /* AMVideoCommands.h */; }; + 71440CC82D54AB460048EA32 /* AMVideoCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = 71440CC62D54AB460048EA32 /* AMVideoCommands.m */; }; + 71440CCF2D54AB9C0048EA32 /* FBScreenRecordingContainer.h in Headers */ = {isa = PBXBuildFile; fileRef = 71440CC92D54AB9C0048EA32 /* FBScreenRecordingContainer.h */; }; + 71440CD02D54AB9C0048EA32 /* FBScreenRecordingRequest.h in Headers */ = {isa = PBXBuildFile; fileRef = 71440CCD2D54AB9C0048EA32 /* FBScreenRecordingRequest.h */; }; + 71440CD12D54AB9C0048EA32 /* FBScreenRecordingPromise.h in Headers */ = {isa = PBXBuildFile; fileRef = 71440CCB2D54AB9C0048EA32 /* FBScreenRecordingPromise.h */; }; + 71440CD22D54AB9C0048EA32 /* FBScreenRecordingPromise.m in Sources */ = {isa = PBXBuildFile; fileRef = 71440CCC2D54AB9C0048EA32 /* FBScreenRecordingPromise.m */; }; + 71440CD32D54AB9C0048EA32 /* FBScreenRecordingContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = 71440CCA2D54AB9C0048EA32 /* FBScreenRecordingContainer.m */; }; + 71440CD42D54AB9C0048EA32 /* FBScreenRecordingRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = 71440CCE2D54AB9C0048EA32 /* FBScreenRecordingRequest.m */; }; + 71440CD72D54AC590048EA32 /* AMVideoRecorder.h in Headers */ = {isa = PBXBuildFile; fileRef = 71440CD52D54AC590048EA32 /* AMVideoRecorder.h */; }; + 71440CD82D54AC590048EA32 /* AMVideoRecorder.m in Sources */ = {isa = PBXBuildFile; fileRef = 71440CD62D54AC590048EA32 /* AMVideoRecorder.m */; }; + 71440CDB2D54AFC60048EA32 /* AMXCTRunnerDaemonSessionWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = 71440CD92D54AFC60048EA32 /* AMXCTRunnerDaemonSessionWrapper.h */; }; + 71440CDC2D54AFC60048EA32 /* AMXCTRunnerDaemonSessionWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 71440CDA2D54AFC60048EA32 /* AMXCTRunnerDaemonSessionWrapper.m */; }; + 71440CDE2D54B2410048EA32 /* AMXCTRunnerDaemonSession.h in Headers */ = {isa = PBXBuildFile; fileRef = 71440CDD2D54B2410048EA32 /* AMXCTRunnerDaemonSession.h */; }; + 71440CE02D54D8C90048EA32 /* AMVideoRecordingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 71440CDF2D54D8C90048EA32 /* AMVideoRecordingTests.m */; }; 714CA6FC2566461100353B27 /* FBDebugCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = 714CA6FA2566461100353B27 /* FBDebugCommands.h */; }; 714CA6FD2566461100353B27 /* FBDebugCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = 714CA6FB2566461100353B27 /* FBDebugCommands.m */; }; 714CA7012566475200353B27 /* XCUIApplication+AMSource.h in Headers */ = {isa = PBXBuildFile; fileRef = 714CA6FF2566475200353B27 /* XCUIApplication+AMSource.h */; }; @@ -193,6 +207,8 @@ 71B8B68126726369009CE50C /* AMSwipeHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 71B8B67F26726369009CE50C /* AMSwipeHelpers.h */; }; 71B8B68226726369009CE50C /* AMSwipeHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 71B8B68026726369009CE50C /* AMSwipeHelpers.m */; }; 71B8B684267265D7009CE50C /* AMVariousElementTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 71B8B683267265D7009CE50C /* AMVariousElementTests.m */; }; + 71E109222D55EBD0008A800D /* AMScreenUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 71E109212D55EBD0008A800D /* AMScreenUtils.m */; }; + 71E109232D55EBD0008A800D /* AMScreenUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = 71E109202D55EBD0008A800D /* AMScreenUtils.h */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -272,6 +288,20 @@ 713A9D362566A83800118D07 /* FBElementCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBElementCommands.m; sourceTree = ""; }; 713A9D3A2566AA2300118D07 /* AMGeometryUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AMGeometryUtils.h; sourceTree = ""; }; 713A9D3B2566AA2300118D07 /* AMGeometryUtils.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AMGeometryUtils.m; sourceTree = ""; }; + 71440CC52D54AB460048EA32 /* AMVideoCommands.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AMVideoCommands.h; sourceTree = ""; }; + 71440CC62D54AB460048EA32 /* AMVideoCommands.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AMVideoCommands.m; sourceTree = ""; }; + 71440CC92D54AB9C0048EA32 /* FBScreenRecordingContainer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBScreenRecordingContainer.h; sourceTree = ""; }; + 71440CCA2D54AB9C0048EA32 /* FBScreenRecordingContainer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBScreenRecordingContainer.m; sourceTree = ""; }; + 71440CCB2D54AB9C0048EA32 /* FBScreenRecordingPromise.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBScreenRecordingPromise.h; sourceTree = ""; }; + 71440CCC2D54AB9C0048EA32 /* FBScreenRecordingPromise.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBScreenRecordingPromise.m; sourceTree = ""; }; + 71440CCD2D54AB9C0048EA32 /* FBScreenRecordingRequest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBScreenRecordingRequest.h; sourceTree = ""; }; + 71440CCE2D54AB9C0048EA32 /* FBScreenRecordingRequest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBScreenRecordingRequest.m; sourceTree = ""; }; + 71440CD52D54AC590048EA32 /* AMVideoRecorder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AMVideoRecorder.h; sourceTree = ""; }; + 71440CD62D54AC590048EA32 /* AMVideoRecorder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AMVideoRecorder.m; sourceTree = ""; }; + 71440CD92D54AFC60048EA32 /* AMXCTRunnerDaemonSessionWrapper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AMXCTRunnerDaemonSessionWrapper.h; sourceTree = ""; }; + 71440CDA2D54AFC60048EA32 /* AMXCTRunnerDaemonSessionWrapper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AMXCTRunnerDaemonSessionWrapper.m; sourceTree = ""; }; + 71440CDD2D54B2410048EA32 /* AMXCTRunnerDaemonSession.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AMXCTRunnerDaemonSession.h; sourceTree = ""; }; + 71440CDF2D54D8C90048EA32 /* AMVideoRecordingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AMVideoRecordingTests.m; sourceTree = ""; }; 714CA6FA2566461100353B27 /* FBDebugCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBDebugCommands.h; sourceTree = ""; }; 714CA6FB2566461100353B27 /* FBDebugCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBDebugCommands.m; sourceTree = ""; }; 714CA6FF2566475200353B27 /* XCUIApplication+AMSource.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "XCUIApplication+AMSource.h"; sourceTree = ""; }; @@ -431,6 +461,8 @@ 71B8B67F26726369009CE50C /* AMSwipeHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AMSwipeHelpers.h; sourceTree = ""; }; 71B8B68026726369009CE50C /* AMSwipeHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AMSwipeHelpers.m; sourceTree = ""; }; 71B8B683267265D7009CE50C /* AMVariousElementTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AMVariousElementTests.m; sourceTree = ""; }; + 71E109202D55EBD0008A800D /* AMScreenUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AMScreenUtils.h; sourceTree = ""; }; + 71E109212D55EBD0008A800D /* AMScreenUtils.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AMScreenUtils.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -634,6 +666,8 @@ 71688B00256469310007F55B /* Commands */ = { isa = PBXGroup; children = ( + 71440CC52D54AB460048EA32 /* AMVideoCommands.h */, + 71440CC62D54AB460048EA32 /* AMVideoCommands.m */, 7180C1D8257A9369008FA870 /* AMActionCommands.h */, 7180C1D9257A9369008FA870 /* AMActionCommands.m */, 712FA08F288BD68100976DA8 /* AMWindowCommands.h */, @@ -659,6 +693,9 @@ 71688B01256469350007F55B /* Routing */ = { isa = PBXGroup; children = ( + 71440CDD2D54B2410048EA32 /* AMXCTRunnerDaemonSession.h */, + 71440CD92D54AFC60048EA32 /* AMXCTRunnerDaemonSessionWrapper.h */, + 71440CDA2D54AFC60048EA32 /* AMXCTRunnerDaemonSessionWrapper.m */, 7151AD3F2564F4C5008B8B2A /* FBCommandHandler.h */, 7151AD4D2564F4C6008B8B2A /* FBCommandStatus.h */, 7151AD442564F4C6008B8B2A /* FBCommandStatus.m */, @@ -680,6 +717,12 @@ 710527DC2565716600130763 /* FBRouteRequest-Private.h */, 7151AD422564F4C6008B8B2A /* FBRouteRequest.h */, 7151AD432564F4C6008B8B2A /* FBRouteRequest.m */, + 71440CC92D54AB9C0048EA32 /* FBScreenRecordingContainer.h */, + 71440CCA2D54AB9C0048EA32 /* FBScreenRecordingContainer.m */, + 71440CCB2D54AB9C0048EA32 /* FBScreenRecordingPromise.h */, + 71440CCC2D54AB9C0048EA32 /* FBScreenRecordingPromise.m */, + 71440CCD2D54AB9C0048EA32 /* FBScreenRecordingRequest.h */, + 71440CCE2D54AB9C0048EA32 /* FBScreenRecordingRequest.m */, 710527F22565744400130763 /* FBSession-Private.h */, 7151AD472564F4C6008B8B2A /* FBSession.h */, 7151AD4B2564F4C6008B8B2A /* FBSession.m */, @@ -697,6 +740,8 @@ 713A9D3B2566AA2300118D07 /* AMGeometryUtils.m */, 71AA30BD25EFF81900151CED /* AMKeyboardUtils.h */, 71AA30BE25EFF81900151CED /* AMKeyboardUtils.m */, + 71E109202D55EBD0008A800D /* AMScreenUtils.h */, + 71E109212D55EBD0008A800D /* AMScreenUtils.m */, 718D2C302567FED3005F533B /* AMSessionCapabilities.h */, 718D2C312567FED3005F533B /* AMSessionCapabilities.m */, 718D2BF125678B4E005F533B /* AMSnapshotUtils.h */, @@ -705,6 +750,8 @@ 7151ADAC2564F570008B8B2A /* AMSettings.m */, 71B8B67F26726369009CE50C /* AMSwipeHelpers.h */, 71B8B68026726369009CE50C /* AMSwipeHelpers.m */, + 71440CD52D54AC590048EA32 /* AMVideoRecorder.h */, + 71440CD62D54AC590048EA32 /* AMVideoRecorder.m */, 71336AF22BD1348F00997FF4 /* AMXCUIDeviceWrapper.h */, 71336AF32BD1348F00997FF4 /* AMXCUIDeviceWrapper.m */, 7180C1C8257A9336008FA870 /* FBBaseActionsSynthesizer.h */, @@ -800,6 +847,7 @@ 718D2C072567A028005F533B /* AMElementAttributesTests.m */, 718D2C202567D8A8005F533B /* AMEditElementTests.m */, 71336AF62BD15B4D00997FF4 /* AMDeviceTests.m */, + 71440CDF2D54D8C90048EA32 /* AMVideoRecordingTests.m */, 71B00E9F2566D9570010DA73 /* AMFindElementTests.m */, 71B00E9C2566D7F10010DA73 /* AMIntegrationTestCase.h */, 71B00E8D2566D4BA0010DA73 /* AMIntegrationTestCase.m */, @@ -834,6 +882,7 @@ 7109C06B2565B607006BFD13 /* Route.h in Headers */, 7109BFB62565B4F0006BFD13 /* FBClassChainQueryParser.h in Headers */, 7109BFCC2565B512006BFD13 /* FBMacros.h in Headers */, + 71440CC72D54AB460048EA32 /* AMVideoCommands.h in Headers */, 7109BFB02565B413006BFD13 /* WebDriverAgentLib.h in Headers */, 718D2C0D2567AA03005F533B /* XCUIElement+AMEditable.h in Headers */, 7109BFD42565B51E006BFD13 /* FBRunLoopSpinner.h in Headers */, @@ -851,6 +900,7 @@ 7109C0202565B593006BFD13 /* FBSession.h in Headers */, 713A9D3C2566AA2300118D07 /* AMGeometryUtils.h in Headers */, 7109C01C2565B58C006BFD13 /* FBSession-Private.h in Headers */, + 71440CDB2D54AFC60048EA32 /* AMXCTRunnerDaemonSessionWrapper.h in Headers */, 7109BFED2565B546006BFD13 /* FBElementUtils.h in Headers */, 7109C05D2565B5F6006BFD13 /* HTTPMessage.h in Headers */, 71B8B67D26725A01009CE50C /* XCUICoordinate+AMSwipe.h in Headers */, @@ -882,6 +932,9 @@ 713A9D312566A14200118D07 /* XCUIApplication+AMActiveElement.h in Headers */, 71A5C67A29A4FAF900421C37 /* FBFailureProofTestCase.h in Headers */, 714CA7062566487B00353B27 /* FBXPath.h in Headers */, + 71440CCF2D54AB9C0048EA32 /* FBScreenRecordingContainer.h in Headers */, + 71440CD02D54AB9C0048EA32 /* FBScreenRecordingRequest.h in Headers */, + 71440CD12D54AB9C0048EA32 /* FBScreenRecordingPromise.h in Headers */, 7109BFF22565B54D006BFD13 /* FBExceptionHandler.h in Headers */, 7109C0532565B5EA006BFD13 /* HTTPErrorResponse.h in Headers */, 7180C1E9257A94F4008FA870 /* XCPointerEvent.h in Headers */, @@ -890,6 +943,7 @@ 718D2C322567FED3005F533B /* AMSessionCapabilities.h in Headers */, 7109BFE02565B536006BFD13 /* FBCommandHandler.h in Headers */, 7109C0732565B611006BFD13 /* RouteResponse.h in Headers */, + 71440CD72D54AC590048EA32 /* AMVideoRecorder.h in Headers */, 7109C04F2565B5E5006BFD13 /* HTTPDataResponse.h in Headers */, 7109C0572565B5EE006BFD13 /* HTTPConnection.h in Headers */, 7109C05B2565B5F3006BFD13 /* HTTPLogging.h in Headers */, @@ -899,6 +953,7 @@ 7180C1D3257A9348008FA870 /* FBW3CActionsHelpers.h in Headers */, 7109C0172565B581006BFD13 /* FBRouteRequest.h in Headers */, 7180C216257AA707008FA870 /* XCUIElement+AMHitPoint.h in Headers */, + 71E109232D55EBD0008A800D /* AMScreenUtils.h in Headers */, 719E6A6E25822DB800777988 /* XCUIApplication+AMUIInterruptions.h in Headers */, 718D2BF325678B4E005F533B /* AMSnapshotUtils.h in Headers */, 7109BFCF2565B517006BFD13 /* FBProtocolHelpers.h in Headers */, @@ -917,6 +972,7 @@ 7109C0612565B5FA006BFD13 /* HTTPResponse.h in Headers */, 7180C203257A9FAC008FA870 /* CDStructures.h in Headers */, 71221BDA2588945400B4FBF5 /* GCDAsyncUdpSocket.h in Headers */, + 71440CDE2D54B2410048EA32 /* AMXCTRunnerDaemonSession.h in Headers */, 7109BFFF2565B55D006BFD13 /* FBResponseJSONPayload.h in Headers */, 7109BFE82565B540006BFD13 /* FBElementCache.h in Headers */, 7109BFF72565B553006BFD13 /* FBExceptions.h in Headers */, @@ -1100,8 +1156,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 71440CDC2D54AFC60048EA32 /* AMXCTRunnerDaemonSessionWrapper.m in Sources */, + 71440CC82D54AB460048EA32 /* AMVideoCommands.m in Sources */, 7109BFDC2565B529006BFD13 /* AMSettings.m in Sources */, 71221BD92588945400B4FBF5 /* GCDAsyncSocket.m in Sources */, + 71E109222D55EBD0008A800D /* AMScreenUtils.m in Sources */, 713A9D3D2566AA2300118D07 /* AMGeometryUtils.m in Sources */, 7109C0712565B60F006BFD13 /* RouteRequest.m in Sources */, 7109C0312565B5AE006BFD13 /* FBScreenshotCommands.m in Sources */, @@ -1114,6 +1173,9 @@ 7109C0792565B618006BFD13 /* RoutingConnection.m in Sources */, 713A9D2325669A0F00118D07 /* FBFindElementCommands.m in Sources */, 7109BFD22565B51C006BFD13 /* FBProtocolHelpers.m in Sources */, + 71440CD22D54AB9C0048EA32 /* FBScreenRecordingPromise.m in Sources */, + 71440CD32D54AB9C0048EA32 /* FBScreenRecordingContainer.m in Sources */, + 71440CD42D54AB9C0048EA32 /* FBScreenRecordingRequest.m in Sources */, 7109C0752565B613006BFD13 /* RouteResponse.m in Sources */, 7109C0402565B5C4006BFD13 /* NSPredicate+FBFormat.m in Sources */, 713A9D2725669A7000118D07 /* XCUIElement+FBFind.m in Sources */, @@ -1131,6 +1193,7 @@ 7109C0692565B605006BFD13 /* HTTPResponseProxy.m in Sources */, 718D2C0E2567AA03005F533B /* XCUIElement+AMEditable.m in Sources */, 71AA30C025EFF81900151CED /* AMKeyboardUtils.m in Sources */, + 71440CD82D54AC590048EA32 /* AMVideoRecorder.m in Sources */, 713A9D382566A83800118D07 /* FBElementCommands.m in Sources */, 7109C05F2565B5F8006BFD13 /* HTTPMessage.m in Sources */, 714CA7022566475200353B27 /* XCUIApplication+AMSource.m in Sources */, @@ -1192,6 +1255,7 @@ 7180C21D257AC27F008FA870 /* AMW3CActionsTests.m in Sources */, 71B8B684267265D7009CE50C /* AMVariousElementTests.m in Sources */, 718D2C292567E6D0005F533B /* AMSessionTests.m in Sources */, + 71440CE02D54D8C90048EA32 /* AMVideoRecordingTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/lib/commands/helpers.ts b/lib/commands/helpers.ts new file mode 100644 index 0000000..8e1f77d --- /dev/null +++ b/lib/commands/helpers.ts @@ -0,0 +1,30 @@ +import _ from 'lodash'; +import { util, fs, net } from 'appium/support'; +import type { Mac2Driver } from '../driver'; +import type { StringRecord } from '@appium/types'; + +export async function uploadRecordedMedia ( + this: Mac2Driver, + localFile: string, + remotePath: string | null, + uploadOptions: StringRecord = {} +): Promise { + if (_.isEmpty(remotePath) || _.isNil(remotePath)) { + const {size} = await fs.stat(localFile); + this.log.debug(`The size of the resulting screen recording is ${util.toReadableSizeString(size)}`); + return (await util.toInMemoryBase64(localFile)).toString(); + } + + const {user, pass, method, headers, fileFieldName, formFields} = uploadOptions; + const options: StringRecord = { + method: method || 'PUT', + headers, + fileFieldName, + formFields, + }; + if (user && pass) { + options.auth = {user, pass}; + } + await net.uploadFile(localFile, remotePath, options); + return ''; +} diff --git a/lib/commands/native-record-screen.ts b/lib/commands/native-record-screen.ts new file mode 100644 index 0000000..b6d8c46 --- /dev/null +++ b/lib/commands/native-record-screen.ts @@ -0,0 +1,180 @@ +import _ from 'lodash'; +import path from 'node:path'; +import { fs, util } from 'appium/support'; +import type { Mac2Driver } from '../driver'; +import { uploadRecordedMedia } from './helpers'; +import type { StringRecord } from '@appium/types'; + +/** + * Initiates a new native screen recording session via XCTest. + * If the screen recording is already running then this call results in noop. + * A screen recording is running until a testing session is finished. + * If a recording has never been stopped explicitly during a test session + * then it would be stopped automatically upon test session termination, + * and leftover videos would be deleted as well. + * + * @since Xcode 15 + * @param fps Frame Per Second setting for the resulting screen recording. 24 by default. + * @param codec Possible codec value, where `0` means H264 (the default setting), `1` means HEVC + * @param displayId Valid display identifier to record the video from. Main display is assumed + * by default. + * @returns The information about the asynchronously running video recording. + */ +export async function macosStartNativeScreenRecording( + this: Mac2Driver, + fps?: number, + codec?: number, + displayId?: number, +): Promise { + const result = await this.wda.proxy.command('/wda/video/start', 'POST', { + fps, + codec, + displayId, + }) as ActiveVideoInfo; + this._recordedVideoIds.add(result.uuid); + return result; +} + +/** + * @since Xcode 15 + * @returns The information about the asynchronously running video recording or + * null if no native video recording has been started. + */ +export async function macosGetNativeScreenRecordingInfo( + this: Mac2Driver +): Promise { + return await this.wda.proxy.command('/wda/video', 'GET') as ActiveVideoInfo | null; +} + +/** + * Stops native screen recordind. + * If no screen recording has been started before then the method throws an exception. + * + * @since Xcode 15 + * @param remotePath The path to the remote location, where the resulting video should be uploaded. + * The following protocols are supported: http/https, ftp. + * Null or empty string value (the default setting) means the content of resulting + * file should be encoded as Base64 and passed as the endpoint response value. + * An exception will be thrown if the generated media file is too big to + * fit into the available process memory. + * @param user The name of the user for the remote authentication. + * @param pass The password for the remote authentication. + * @param method The http multipart upload method name. The 'PUT' one is used by default. + * @param headers Additional headers mapping for multipart http(s) uploads + * @param fileFieldName The name of the form field, where the file content BLOB should + * be stored for http(s) uploads + * @param formFields Additional form fields for multipart http(s) uploads + * @returns Base64-encoded content of the recorded media file if 'remotePath' + * parameter is falsy or an empty string. + * @throws {Error} If there was an error while getting the name of a media file + * or the file content cannot be uploaded to the remote location + * or screen recording is not supported on the device under test. + */ +export async function macosStopNativeScreenRecording( + this: Mac2Driver, + remotePath?: string, + user?: string, + pass?: string, + method?: string, + headers?: StringRecord|[string, any][], + fileFieldName?: string, + formFields?: StringRecord|[string, string][], +): Promise { + const response: ActiveVideoInfo | null = ( + await this.wda.proxy.command('/wda/video/stop', 'POST', {}) + ) as ActiveVideoInfo | null; + if (!response || !_.isPlainObject(response)) { + throw new Error( + 'There is no active screen recording, thus nothing to stop. Did you start it before?' + ); + } + + const { uuid } = response; + const matchedVideoPath = _.first( + (await listAttachments()).filter((name) => name.endsWith(uuid)) + ); + if (!matchedVideoPath) { + throw new Error( + `The screen recording identified by ${uuid} has not been found. Is it accessible?` + ); + } + const options = { + user, + pass, + method, + headers, + fileFieldName, + formFields + }; + const result = await uploadRecordedMedia.bind(this)(matchedVideoPath, remotePath, options); + await cleanupNativeRecordedVideos.bind(this)(uuid); + this._recordedVideoIds.delete(uuid); + return result; +} + +/** + * Deletes previously recorded videos with given ids. + * This call is safe and does not raise any errors. + * + * @param uuids One or more video UUIDs to be deleted + */ +export async function cleanupNativeRecordedVideos( + this: Mac2Driver, + uuids: string | Set, +): Promise { + const attachments = await listAttachments(); + if (_.isEmpty(attachments)) { + return; + } + const tasks: Promise[] = attachments + .map((attachmentPath) => [path.basename(attachmentPath), attachmentPath]) + .filter(([name,]) => _.isString(uuids) ? uuids === name : uuids.has(name)) + .map(([, attachmentPath]) => fs.rimraf(attachmentPath)); + if (_.isEmpty(tasks)) { + return; + } + try { + await Promise.all(tasks); + this.log.debug( + `Successfully deleted ${util.pluralize('leftover video recording', tasks.length, true)}` + ); + } catch (e) { + this.log.warn(`Could not cleanup some leftover video recordings: ${e.message}`); + } +} + +/** + * Fetches information about available displays + * + * @returns A map where keys are display identifiers and values are display infos + */ +export async function macosListDisplays(this: Mac2Driver): Promise> { + return await this.wda.proxy.command('/wda/displays/list', 'GET') as StringRecord; +} + +// #region Private functions + +async function listAttachments(): Promise { + // The expected path looks like + // $HOME/Library/Daemon Containers/EFDD24BF-F856-411F-8954-CD5F0D6E6F3E/Data/Attachments/CAE7E5E2-5AC9-4D33-A47B-C491D644DE06 + const deamonContainersRoot = path.resolve(process.env.HOME as string, 'Library', 'Daemon Containers'); + return await fs.glob(`*/Data/Attachments/*`, { + cwd: deamonContainersRoot, + absolute: true, + }); +} + +interface ActiveVideoInfo { + fps: number; + codec: number; + displayId: number; + uuid: string; + startedAt: number; +} + +interface DisplayInfo { + id: number; + isMain: boolean; +} + +// #endregion \ No newline at end of file diff --git a/lib/commands/record-screen.js b/lib/commands/record-screen.js index 5dc2644..ba338a2 100644 --- a/lib/commands/record-screen.js +++ b/lib/commands/record-screen.js @@ -1,8 +1,9 @@ import _ from 'lodash'; import { waitForCondition } from 'asyncbox'; -import { util, fs, net, tempDir } from 'appium/support'; +import { util, fs, tempDir } from 'appium/support'; import { SubProcess } from 'teen_process'; import B from 'bluebird'; +import { uploadRecordedMedia } from './helpers'; const RETRY_PAUSE = 300; @@ -14,35 +15,6 @@ const FFMPEG_BINARY = 'ffmpeg'; const DEFAULT_FPS = 15; const DEFAULT_PRESET = 'veryfast'; -/** - * - * @this {Mac2Driver} - * @param {string} localFile - * @param {string?} remotePath - * @param {import('@appium/types').StringRecord} [uploadOptions={}] - * @returns - */ -async function uploadRecordedMedia (localFile, remotePath = null, uploadOptions = {}) { - if (_.isEmpty(remotePath) || _.isNil(remotePath)) { - const {size} = await fs.stat(localFile); - this.log.debug(`The size of the resulting screen recording is ${util.toReadableSizeString(size)}`); - return (await util.toInMemoryBase64(localFile)).toString(); - } - - const {user, pass, method, headers, fileFieldName, formFields} = uploadOptions; - const options = { - method: method || 'PUT', - headers, - fileFieldName, - formFields, - }; - if (user && pass) { - options.auth = {user, pass}; - } - await net.uploadFile(localFile, remotePath, options); - return ''; -} - /** * @param {import('@appium/types').AppiumLogger} log */ diff --git a/lib/driver.js b/lib/driver.js index 29666c0..3b49521 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -14,6 +14,7 @@ import * as sourceCommands from './commands/source'; import log from './logger'; import { newMethodMap } from './method-map'; import { executeMethodMap } from './execute-method-map'; +import * as nativeScreenRecordingCommands from './commands/native-record-screen'; /** @type {import('@appium/types').RouteMatcher[]} */ const NO_PROXY = [ @@ -34,6 +35,9 @@ export class Mac2Driver extends BaseDriver { /** @type {import('./wda-mac').WDAMacServer} */ wda; + /** @type {Set} */ + _recordedVideoIds; + static newMethodMap = newMethodMap; static executeMethodMap = executeMethodMap; @@ -71,6 +75,7 @@ export class Mac2Driver extends BaseDriver { this.wda = null; this.proxyReqRes = null; this.isProxyActive = false; + this._recordedVideoIds = new Set(); this._screenRecorder = null; } @@ -131,6 +136,14 @@ export class Mac2Driver extends BaseDriver { async deleteSession () { await this._screenRecorder?.stop(true); + if (!_.isEmpty(this._recordedVideoIds)) { + try { + await this.wda.proxy.command('/wda/video/stop', 'POST', {}); + } catch {} + await nativeScreenRecordingCommands.cleanupNativeRecordedVideos.bind(this)( + this._recordedVideoIds + ); + } await this.wda.stopSession(); if (this.opts.postrun) { @@ -187,6 +200,11 @@ export class Mac2Driver extends BaseDriver { startRecordingScreen = recordScreenCommands.startRecordingScreen; stopRecordingScreen = recordScreenCommands.stopRecordingScreen; + macosStartNativeScreenRecording = nativeScreenRecordingCommands.macosStartNativeScreenRecording; + macosGetNativeScreenRecordingInfo = nativeScreenRecordingCommands.macosGetNativeScreenRecordingInfo; + macosStopNativeScreenRecording = nativeScreenRecordingCommands.macosStopNativeScreenRecording; + macosListDisplays = nativeScreenRecordingCommands.macosListDisplays; + macosScreenshots = screenshotCommands.macosScreenshots; macosSource = sourceCommands.macosSource; diff --git a/lib/execute-method-map.ts b/lib/execute-method-map.ts index 1f2fbe4..5afaa59 100644 --- a/lib/execute-method-map.ts +++ b/lib/execute-method-map.ts @@ -302,4 +302,34 @@ export const executeMethodMap = { ], }, }, + 'macos: startNativeScreenRecording': { + command: 'macosStartNativeScreenRecording', + params: { + optional: [ + 'fps', + 'codec', + 'displayId', + ], + }, + }, + 'macos: getNativeScreenRecordingInfo': { + command: 'macosGetNativeScreenRecordingInfo', + }, + 'macos: stopNativeScreenRecording': { + command: 'macosStopNativeScreenRecording', + params: { + optional: [ + 'remotePath', + 'user', + 'pass', + 'method', + 'headers', + 'fileFieldName', + 'formFields' + ], + }, + }, + 'macos: listDisplays': { + command: 'macosListDisplays', + }, } as const satisfies ExecuteMethodMap;