From 59205d8bbbd308527c6e21322cc73c59fd90070e Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 7 Mar 2024 08:54:46 +0100 Subject: [PATCH 1/6] feat: Video recording using native XCTest backend --- lib/commands/index.js | 2 + lib/commands/log.js | 2 +- lib/commands/xctest-record-screen.js | 193 +++++++++++++++++++++++++++ lib/devicectl.js | 77 ++++++++++- lib/driver.js | 11 +- lib/execute-method-map.ts | 15 +++ 6 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 lib/commands/xctest-record-screen.js diff --git a/lib/commands/index.js b/lib/commands/index.js index 9fb931450..ff180a9d1 100644 --- a/lib/commands/index.js +++ b/lib/commands/index.js @@ -40,6 +40,7 @@ import sourceExtensions from './source'; import timeoutExtensions from './timeouts'; import webExtensions from './web'; import xctestExtensions from './xctest'; +import xctestRecordScreenExtensions from './xctest-record-screen'; export default { activeAppInfoExtensions, @@ -84,4 +85,5 @@ export default { timeoutExtensions, webExtensions, xctestExtensions, + xctestRecordScreenExtensions, }; diff --git a/lib/commands/log.js b/lib/commands/log.js index d99ced49b..c7fd31696 100644 --- a/lib/commands/log.js +++ b/lib/commands/log.js @@ -50,7 +50,7 @@ const SUPPORTED_LOG_TYPES = { * @returns {AppiumServerLogEntry[]} */ getter: (self) => { - self.ensureFeatureEnabled(GET_SERVER_LOGS_FEATURE); + self.assertFeatureEnabled(GET_SERVER_LOGS_FEATURE); return log.unwrap().record.map((x) => ({ timestamp: Date.now(), level: 'ALL', diff --git a/lib/commands/xctest-record-screen.js b/lib/commands/xctest-record-screen.js new file mode 100644 index 000000000..c9c7d364d --- /dev/null +++ b/lib/commands/xctest-record-screen.js @@ -0,0 +1,193 @@ +import _ from 'lodash'; +import {fs, util} from 'appium/support'; +import {encodeBase64OrUpload} from '../utils'; +import path from 'node:path'; +import {Devicectl} from '../devicectl'; + +const MOV_EXT = '.mov'; +const FEATURE_NAME = 'xctest_screen_record'; +const DOMAIN_IDENTIFIER = 'com.apple.testmanagerd'; +const DOMAIN_TYPE = 'appDataContainer'; +const USERNAME = 'mobile'; +const SUBDIRECTORY = 'Attachments'; + +/** + * @typedef {Object} XcTestScreenRecordingInfo + * @property {string} uuid Unique identifier of the video being recorded + * @property {number} fps FPS value + * @property {number} codec Video codec, where 0 is h264 + * @property {number} startedAt The timestamp when the screen recording has started in float Unix seconds + */ + +/** + * @typedef {XcTestScreenRecordingInfo} XcTestScreenRecording + * @property {string} payload Base64-encoded content of the recorded media + * file if `remotePath` parameter is empty or null or an empty string. + */ + +/** + * @this {XCUITestDriver} + * @param {string} uuid Unique identifier of the video being recorded + * @returns {Promise} The full path to the screen recording movie + */ +async function retrieveRecodingFromSimulator(uuid) { + // @ts-ignore The property is there + const device = this.opts.device; + const dataRoot = /** @type {string} */ (device.getDir()); + // On Simulators the path looks like + // $HOME/Library/Developer/CoreSimulator/Devices/F8E1968A-8443-4A9A-AB86-27C54C36A2F6/data/Containers/Data/InternalDaemon/4E3FE8DF-AD0A-41DA-B6EC-C35E5798C219/Attachments/A044DAF7-4A58-4CD5-95C3-29B4FE80C377 + const internalDaemonRoot = path.resolve(dataRoot, 'Containers', 'Data', 'InternalDaemon'); + const videoPaths = await fs.glob(`*/Attachments/${uuid}`, { + cwd: internalDaemonRoot, + absolute: true, + }); + if (_.isEmpty(videoPaths)) { + throw new Error( + `Unable to locate XCTest screen recording identified by '${uuid}' for the Simulator ${device.udid}` + ); + } + const videoPath = videoPaths[0]; + const {size} = await fs.stat(videoPath); + this.log.debug(`Located the video at '${videoPath}' (${util.toReadableSizeString(size)})`); + return videoPath; +} + +/** + * @this {XCUITestDriver} + * @param {string} uuid Unique identifier of the video being recorded + * @returns {Promise} The full path to the screen recording movie + */ +async function retrieveRecodingFromRealDevice(uuid) { + const devicectl = new Devicectl(this.opts.udid, this.log); + const fileNames = await devicectl.listFiles(DOMAIN_TYPE, DOMAIN_IDENTIFIER, { + username: USERNAME, + subdirectory: SUBDIRECTORY, + }); + if (!fileNames.includes(uuid)) { + throw new Error( + `Unable to locate XCTest screen recording identified by '${uuid}' for the device ${this.opts.udid}` + ); + } + const videoPath = path.join(/** @type {string} */ (this.opts.tmpDir), `${uuid}${MOV_EXT}`); + await devicectl.pullFile(`${SUBDIRECTORY}/${uuid}`, videoPath, { + username: USERNAME, + domainIdentifier: DOMAIN_IDENTIFIER, + domainType: DOMAIN_TYPE, + }); + const {size} = await fs.stat(videoPath); + this.log.debug(`Pulled the video to '${videoPath}' (${util.toReadableSizeString(size)})`); + return videoPath; +} + +/** + * @this {XCUITestDriver} + * @param {string} uuid Unique identifier of the video being recorded + * @returns {Promise} The full path to the screen recording movie + */ +async function retrieveXcTestScreenRecording(uuid) { + return this.isRealDevice() + ? await retrieveRecodingFromRealDevice.bind(this)(uuid) + : await retrieveRecodingFromSimulator.bind(this)(uuid); +} + +export default { + /** + * Direct Appium to start recording the device screen + * + * Record the display of devices running iOS Simulator since Xcode 9 or real devices since iOS 11 + * (ffmpeg utility is required: 'brew install ffmpeg'). + * It records screen activity to a MPEG-4 file. Audio is not recorded with the video file. + * If screen recording has been already started then the command will stop it forcefully and start a new one. + * The previously recorded video file will be deleted. + * + * @param {number} [fps] FPS value + * @param {number} [codec] Video codec, where 0 is h264 + * @returns {Promise} + * @throws {Error} If screen recording has failed to start. + * @this {XCUITestDriver} + */ + async mobileStartXctestScreenRecording(fps, codec) { + if (this.isRealDevice()) { + // This feature might be used to abuse real devices as there is no + // reliable way (yet) to cleanup video recordings stored there + // by the testmanagerd daemon + this.assertFeatureEnabled(FEATURE_NAME); + } + + const opts = {}; + if (_.isInteger(codec)) { + opts.codec = codec; + } + if (_.isInteger(fps)) { + opts.fps = fps; + } + const response = /** @type {XcTestScreenRecordingInfo} */ ( + await this.proxyCommand('/wda/video/start', 'POST', opts) + ); + this.log.info(`Started a new screen recording: ${JSON.stringify(response)}`); + return response; + }, + + /** + * + * @returns {Promise} + */ + async mobileGetXctestScreenRecordingInfo() { + return /** @type {XcTestScreenRecordingInfo?} */ ( + await this.proxyCommand('/wda/video', 'GET') + ); + }, + + /** + * Direct Appium to stop screen recording and return the video + * + * If no screen recording process is running then the endpoint will try to get + * the recently recorded file. If no previously recorded file is found and no + * active screen recording processes are running then the method returns an + * empty string. + * + * @param {string} [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 {string} [user] The name of the user for the remote authentication. + * Only works if `remotePath` is provided. + * @param {string} [pass] The password for the remote authentication. + * Only works if `remotePath` is provided. + * @param {import('@appium/types').HTTPHeaders} [headers] Additional headers mapping for multipart http(s) uploads + * @param {string} [fileFieldName] The name of the form field where the file content BLOB should be stored for + * http(s) uploads + * @param {Record | [string, any][]} [formFields] Additional form fields for multipart http(s) uploads + * @param {'PUT' | 'POST' | 'PATCH'} [method='PUT'] The http multipart upload method name. + * Only works if `remotePath` is provided. + * @returns {Promise} + * @throws {Error} If there was an error while retrieving the video + * file or the file content cannot be uploaded to the remote location. + * @this {XCUITestDriver} + */ + async mobileStopXctestScreenRecording(remotePath, user, pass, headers, fileFieldName, formFields, method) { + const screenRecordingInfo = await this.mobileGetXctestScreenRecordingInfo(); + if (!screenRecordingInfo) { + throw new Error('There is no active screen recording. Did you start one beforehand?'); + } + + this.log.debug(`Stopping the active screen recording: ${JSON.stringify(screenRecordingInfo)}`); + await this.proxyCommand('/wda/video/stop', 'POST', {}); + const videoPath = await retrieveXcTestScreenRecording.bind(this)(screenRecordingInfo.uuid); + try { + screenRecordingInfo.payload = await encodeBase64OrUpload(videoPath, remotePath, { + user, pass, headers, fileFieldName, formFields, method + }); + } finally { + await fs.rimraf(videoPath); + } + return screenRecordingInfo; + }, +}; + +/** + * @typedef {import('../driver').XCUITestDriver} XCUITestDriver + */ diff --git a/lib/devicectl.js b/lib/devicectl.js index 954b9a5fd..d8df9725b 100644 --- a/lib/devicectl.js +++ b/lib/devicectl.js @@ -56,12 +56,31 @@ const XCRUN = 'xcrun'; * @property {boolean} [asJson=true] * @property {boolean} [asynchronous=false] * @property {string[]|string} [subcommandOptions] + * @property {number} [timeout] */ /** * @typedef {{asynchronous: true}} TAsyncOpts */ +/** + * @typedef {Object} ListFilesOptions + * @property {string} [username] The username of the user we should target. Only relevant for certain domains. + * @property {string} [subdirectory] A subdirectory within the domain. If not specified, defaults to the root. + */ + +/** + * @typedef {Object} PullFileOptions + * @property {string} [username] The username of the user we should target. Only relevant for certain domains. + * @property {string} domainType The file service domain. Valid values are: temporary, rootStaging, appDataContainer, appGroupDataContainer, + * systemCrashLogs. You must specify a valid domain and identifier pair. Each domain is accompanied by an identifier + * that provides additional context. For example, if the domain is an app data container, the identifier is the bundle + * ID of the app. For temporary directories and root staging areas, the identifier is a unique client-provided string + * which is used to get your own space, separate from those of other clients. + * @property {string} domainIdentifier A unique string used to provide additional context to the domain. + * @property {number} [timeout=120000] The timeout for pulling a file in milliseconds. + */ + export class Devicectl { /** * @since Xcode 15, iOS 17 @@ -85,6 +104,7 @@ export class Devicectl { asynchronous = false, asJson = true, subcommandOptions, + timeout, } = opts ?? {}; const finalArgs = [ @@ -108,7 +128,7 @@ export class Devicectl { // @ts-ignore TS does not understand it return result; } - const result = await exec(XCRUN, finalArgs); + const result = await exec(XCRUN, finalArgs, {timeout}); if (logStdout) { this.log.debug(`Command output: ${result.stdout}`); } @@ -141,6 +161,61 @@ export class Devicectl { return JSON.parse(stdout).result.runningProcesses; } + /** + * Lists files at a specified path on the device + * + * @param {string} domainType The file service domain. Valid values are: temporary, rootStaging, appDataContainer, appGroupDataContainer, + * systemCrashLogs. You must specify a valid domain and identifier pair. Each domain is accompanied by an identifier + * that provides additional context. For example, if the domain is an app data container, the identifier is the bundle + * ID of the app. For temporary directories and root staging areas, the identifier is a unique client-provided string + * which is used to get your own space, separate from those of other clients. + * @param {string} domainIdentifier A unique string used to provide additional context to the domain. + * @param {ListFilesOptions} [opts={}] + * @returns {Promise} + */ + async listFiles(domainType, domainIdentifier, opts = {}) { + const subcommandOptions = [ + '--domain-type', domainType, + '--domain-identifier', domainIdentifier, + ]; + if (opts.username) { + subcommandOptions.push('--username', opts.username); + } + if (opts.subdirectory) { + subcommandOptions.push('--subdirectory', opts.subdirectory); + } + const {stdout} = await this.execute(['device', 'info', 'files'], { + subcommandOptions, + }); + return JSON.parse(stdout).result.files; + } + + /** + * Pulls a file from the specified path on the device to a local file system + * + * @param {string} from The item which should be copied. + * @param {string} to The location to which the item should be copied. + * @param {PullFileOptions} opts + * @returns {Promise} + */ + async pullFile(from, to, opts) { + const subcommandOptions = [ + '--domain-type', opts.domainType, + '--domain-identifier', opts.domainIdentifier, + '--source', from, + '--destination', to, + ]; + if (opts.username) { + subcommandOptions.push('--user', opts.username); + } + await this.execute(['device', 'copy', 'from'], { + subcommandOptions, + timeout: opts.timeout ?? 120000, + asJson: false, + }); + return to; + } + /** * Send POSIX signal to the running process * diff --git a/lib/driver.js b/lib/driver.js index ce7d2fd2d..a55f153e5 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -664,7 +664,7 @@ class XCUITestDriver extends BaseDriver { const device = this.opts.device; if (this.opts.shutdownOtherSimulators) { - this.ensureFeatureEnabled(SHUTDOWN_OTHER_FEAT_NAME); + this.assertFeatureEnabled(SHUTDOWN_OTHER_FEAT_NAME); await shutdownOtherSimulators(device); } @@ -789,7 +789,7 @@ class XCUITestDriver extends BaseDriver { // Used in the following WDA build if (this.opts.resultBundlePath) { - this.ensureFeatureEnabled(CUSTOMIZE_RESULT_BUNDPE_PATH); + this.assertFeatureEnabled(CUSTOMIZE_RESULT_BUNDPE_PATH); } const startupRetries = @@ -2222,6 +2222,13 @@ class XCUITestDriver extends BaseDriver { mobileInstallXCTestBundle = commands.xctestExtensions.mobileInstallXCTestBundle; mobileListXCTestBundles = commands.xctestExtensions.mobileListXCTestBundles; mobileListXCTestsInTestBundle = commands.xctestExtensions.mobileListXCTestsInTestBundle; + + /*----------------------+ + | XCTEST SCREEN RECORD | + +---------------------+*/ + mobileStartXctestScreenRecording = commands.mobileStartXctestScreenRecording; + mobileGetXctestScreenRecordingInfo = commands.mobileGetXctestScreenRecordingInfo; + mobileStopXctestScreenRecording = commands.mobileStopXctestScreenRecording; } /** diff --git a/lib/execute-method-map.ts b/lib/execute-method-map.ts index 8af8e862d..c638a9464 100644 --- a/lib/execute-method-map.ts +++ b/lib/execute-method-map.ts @@ -391,6 +391,21 @@ export const executeMethodMap = { required: ['bundle'], }, }, + 'mobile: startXCTestScreenRecording': { + command: 'mobileStartXctestScreenRecording', + params: { + optional: ['fps', 'codec'], + }, + }, + 'mobile: getXCTestScreenRecordingInfo': { + command: 'mobileGetXctestScreenRecordingInfo', + }, + 'mobile: stp[XCTestScreenRecording': { + command: 'mobileStopXctestScreenRecording', + params: { + optional: ['remotePath', 'user', 'pass', 'headers', 'fileFieldName', 'formFields', 'method'], + }, + }, 'mobile: pushNotification': { command: 'mobilePushNotification', params: { From ea4677d219fe97f2464425610e8e7fb0d6573014 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 7 Mar 2024 17:57:37 +0100 Subject: [PATCH 2/6] finalize --- docs/reference/execute-methods.md | 77 ++++++++++++++++++++++++++++ lib/commands/xctest-record-screen.js | 39 +++++++++----- lib/devicectl.js | 6 +-- lib/execute-method-map.ts | 2 +- package.json | 2 +- 5 files changed, 107 insertions(+), 19 deletions(-) diff --git a/docs/reference/execute-methods.md b/docs/reference/execute-methods.md index 100903d10..cb8f9225b 100644 --- a/docs/reference/execute-methods.md +++ b/docs/reference/execute-methods.md @@ -1513,3 +1513,80 @@ elementAttributes | dict | JSON object containing various attributes of the elem "rawIdentifier":null } ``` + +### mobile: startXCTestScreenRecording + +Start a new screen recording via XCTest. + +Since this feature is based on the native implemntation provided by Apple +it provides the best quality for the least perfomance penalty. + +Even though the feature is available for real devices +there is no possibility to delete stored video files yet, +which may lead to internal storage overload. +That is why it was put under the `xctest_screen_record` security +feature flag if executed from a real device test. + +If the screen recording is already running this API is a noop. + +This feature is only available since Xcode 15/iOS 17. + +#### Arguments + +Name | Type | Required | Description | Example +--- | --- | --- | --- | --- +fps | number | no | The Frames Per Second value for the resulting video. Providing higher values will create video files that are greater in size, but with smoother transitions. It is highly recommeneded to keep this value is range 1-60. 24 by default | 60 + +#### Returned Result + +The API response consists of the following entries: + +Name | Type | Description | Example +--- | --- | --- | --- +uuid | string | Unique identifier of the video being recorded | 1D988774-C7E2-4817-829D-3B835DDAA7DF +fps | numner | FPS value | 24 +codec | number | The magic for the used codec. Value of zero means h264 video codec is being used | 0 +startedAt | number | The timestamp when the screen recording has started in float seconds since Unix epoch | 1709826124.123 + +### mobile: getXCTestScreenRecordingInfo + +Retrieves information about the current running screen recording. +If no screen recording is running then `null` is returned. + +#### Returned Result + +Same as for [mobile: startXCTestScreenRecording](#mobile-startxctestscreenrecording) + +### mobile: stopXCTestScreenRecording + +Stops the current XCTest screen recording previously started by the +[mobile: startXctestScreenRecording](#mobile-startxctestscreenrecording) API. + +An error is thrown if no screen recording is running. + +The resulting movie is returned as base-64 string or is uploaded to +a remote location if corresponding options have been provided. + +The resulting movie is automatically deleted from the local file system **FOR SIMULATORS ONLY**. +In order to clean it up from a real device it is necessary to properly +shut down XCTest by calling `POST /wda/shutdown` API or by doing device factory reset. + +#### Arguments + +Name | Type | Required | Description | Example +--- | --- | --- | --- | --- +remotePath | string | no | The path to the remote location, where the resulting .mov file 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 to the endpoint response value. An exception will be thrown if the generated file is too big to fit into the available process memory. | https://myserver/upload +user | string | no | The name of the user for the remote authentication. Only works if `remotePath` is provided. | myuser +pass | string | no | The password for the remote authentication. Only works if `remotePath` is provided. | mypassword +method | string | no | The http multipart upload method name. Only works if `remotePath` is provided. `PUT` by default | POST +headers | dict | no | Additional headers mapping for multipart http(s) uploads | {'User-Agent': 'Myserver 1.0'} +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 | dict or array | no | Additional form fields for multipart http(s) uploads | {'field2': 'value2'} + +#### Returned Result + +Same as for [mobile: startXCTestScreenRecording](#mobile-startxctestscreenrecording) plus the below entry: + +Name | Type | Description | Example +--- | --- | --- | --- +payload | string | Base64-encoded content of the recorded media file if `remotePath` parameter is empty/null or an empty string otherwise. The resulting media is expected to a be a valid QuickTime movie (.mov). | `YXBwaXVt....` diff --git a/lib/commands/xctest-record-screen.js b/lib/commands/xctest-record-screen.js index c9c7d364d..20ee77679 100644 --- a/lib/commands/xctest-record-screen.js +++ b/lib/commands/xctest-record-screen.js @@ -22,7 +22,8 @@ const SUBDIRECTORY = 'Attachments'; /** * @typedef {XcTestScreenRecordingInfo} XcTestScreenRecording * @property {string} payload Base64-encoded content of the recorded media - * file if `remotePath` parameter is empty or null or an empty string. + * file if `remotePath` parameter is empty or null or an empty string otherwise. + * The media is expected to a be a valid QuickTime movie (.mov). */ /** @@ -92,17 +93,20 @@ async function retrieveXcTestScreenRecording(uuid) { export default { /** - * Direct Appium to start recording the device screen + * Start a new screen recording via XCTest. * - * Record the display of devices running iOS Simulator since Xcode 9 or real devices since iOS 11 - * (ffmpeg utility is required: 'brew install ffmpeg'). - * It records screen activity to a MPEG-4 file. Audio is not recorded with the video file. - * If screen recording has been already started then the command will stop it forcefully and start a new one. - * The previously recorded video file will be deleted. + * Even though the feature is available for real devices + * there is no possibility to delete stored video files yet, + * which may lead to internal storage overload. + * That is why it was put under a security feature flag. * + * If the recording is already running this API is a noop. + * + * @since Xcode 15/iOS 17 * @param {number} [fps] FPS value - * @param {number} [codec] Video codec, where 0 is h264 - * @returns {Promise} + * @param {number} [codec] Video codec, where 0 is h264, 1 is HEVC + * @returns {Promise} The information + * about a newly created or a running the screen recording. * @throws {Error} If screen recording has failed to start. * @this {XCUITestDriver} */ @@ -129,6 +133,8 @@ export default { }, /** + * Retrieves information about the current running screen recording. + * If no screen recording is running then `null` is returned. * * @returns {Promise} */ @@ -139,13 +145,18 @@ export default { }, /** - * Direct Appium to stop screen recording and return the video + * Stop screen recording previously started by mobileStartXctestScreenRecording API. + * + * An error is thrown if no screen recording is running. + * + * The resulting movie is returned as base-64 string or is uploaded to + * a remote location if corresponding options have been provided. * - * If no screen recording process is running then the endpoint will try to get - * the recently recorded file. If no previously recorded file is found and no - * active screen recording processes are running then the method returns an - * empty string. + * The resulting movie is automatically deleted FOR SIMULATORS ONLY. + * In order to clean it up from a real device it is necessary to properly + * shut down XCTest by calling `POST /wda/shutdown` API or by doing factory reset. * + * @since Xcode 15/iOS 17 * @param {string} [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 diff --git a/lib/devicectl.js b/lib/devicectl.js index d8df9725b..0a61a62ac 100644 --- a/lib/devicectl.js +++ b/lib/devicectl.js @@ -171,7 +171,7 @@ export class Devicectl { * which is used to get your own space, separate from those of other clients. * @param {string} domainIdentifier A unique string used to provide additional context to the domain. * @param {ListFilesOptions} [opts={}] - * @returns {Promise} + * @returns {Promise} List of file names (could be empty) */ async listFiles(domainType, domainIdentifier, opts = {}) { const subcommandOptions = [ @@ -187,7 +187,7 @@ export class Devicectl { const {stdout} = await this.execute(['device', 'info', 'files'], { subcommandOptions, }); - return JSON.parse(stdout).result.files; + return JSON.parse(stdout).result.files.map(({name}) => name); } /** @@ -196,7 +196,7 @@ export class Devicectl { * @param {string} from The item which should be copied. * @param {string} to The location to which the item should be copied. * @param {PullFileOptions} opts - * @returns {Promise} + * @returns {Promise} The destination path (same as `to`) */ async pullFile(from, to, opts) { const subcommandOptions = [ diff --git a/lib/execute-method-map.ts b/lib/execute-method-map.ts index c638a9464..bff9df7bb 100644 --- a/lib/execute-method-map.ts +++ b/lib/execute-method-map.ts @@ -400,7 +400,7 @@ export const executeMethodMap = { 'mobile: getXCTestScreenRecordingInfo': { command: 'mobileGetXctestScreenRecordingInfo', }, - 'mobile: stp[XCTestScreenRecording': { + 'mobile: stopXCTestScreenRecording': { command: 'mobileStopXctestScreenRecording', params: { optional: ['remotePath', 'user', 'pass', 'headers', 'fileFieldName', 'formFields', 'method'], diff --git a/package.json b/package.json index c906a28d4..41a2fdf7d 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "appium-ios-device": "^2.5.4", "appium-ios-simulator": "^5.5.1", "appium-remote-debugger": "^11.0.0", - "appium-webdriveragent": "^7.0.1", + "appium-webdriveragent": "^7.1.0", "appium-xcode": "^5.1.4", "async-lock": "^1.4.0", "asyncbox": "^3.0.0", From 326168e4804e2262b62f2092d3725fb45b07dfd8 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 7 Mar 2024 18:01:50 +0100 Subject: [PATCH 3/6] wording --- docs/reference/execute-methods.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/reference/execute-methods.md b/docs/reference/execute-methods.md index cb8f9225b..c2ab955bd 100644 --- a/docs/reference/execute-methods.md +++ b/docs/reference/execute-methods.md @@ -1518,18 +1518,19 @@ elementAttributes | dict | JSON object containing various attributes of the elem Start a new screen recording via XCTest. -Since this feature is based on the native implemntation provided by Apple -it provides the best quality for the least perfomance penalty. +Since this feature is based on the native implementation provided by Apple +it provides the best quality for the least perfomance penalty in comparison +to alternative implementations. Even though the feature is available for real devices -there is no possibility to delete stored video files yet, +there is no possibility to delete video files stored on the device yet, which may lead to internal storage overload. That is why it was put under the `xctest_screen_record` security feature flag if executed from a real device test. If the screen recording is already running this API is a noop. -This feature is only available since Xcode 15/iOS 17. +The feature is only available since Xcode 15/iOS 17. #### Arguments From 1b0137d84c2c450fccec4eede8dacfefc837ae4e Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 7 Mar 2024 18:16:03 +0100 Subject: [PATCH 4/6] linter --- lib/commands/app-management.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/commands/app-management.js b/lib/commands/app-management.js index dff5ad8fb..ed5f72597 100644 --- a/lib/commands/app-management.js +++ b/lib/commands/app-management.js @@ -107,7 +107,7 @@ export default { /** @type { {arguments?: any[], environment?: any, bundleId: string} } */ const launchOptions = {bundleId}; if (args) { - launchOptions.arguments = _.isArray(args) ? args : [args]; + launchOptions.arguments = Array.isArray(args) ? args : [args]; } if (environment) { launchOptions.environment = environment; @@ -182,6 +182,7 @@ export default { * @this {XCUITestDriver} */ async installApp(appPath, {timeoutMs, strategy} = {}) { + // @ts-ignore Probably a typescript bug await this.mobileInstallApp(appPath, timeoutMs, strategy); }, /** @@ -191,6 +192,7 @@ export default { */ async activateApp(bundleId, opts = {}) { const {environment, arguments: args} = opts; + // @ts-ignore Probably a typescript bug return await this.mobileLaunchApp(bundleId, args, environment); }, /** @@ -309,6 +311,7 @@ export default { } }; if (seconds && !_.isNumber(seconds) && _.has(seconds, 'timeout')) { + // @ts-ignore This is ok const timeout = seconds.timeout; selectEndpoint(isNaN(Number(timeout)) ? timeout : parseFloat(String(timeout)) / 1000.0); } else { From 2473d6b8de81ede20444812c75ded64ced8eb07d Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 7 Mar 2024 18:28:14 +0100 Subject: [PATCH 5/6] fix properties --- lib/driver.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/driver.js b/lib/driver.js index a55f153e5..a2f9dc5db 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -2226,9 +2226,9 @@ class XCUITestDriver extends BaseDriver { /*----------------------+ | XCTEST SCREEN RECORD | +---------------------+*/ - mobileStartXctestScreenRecording = commands.mobileStartXctestScreenRecording; - mobileGetXctestScreenRecordingInfo = commands.mobileGetXctestScreenRecordingInfo; - mobileStopXctestScreenRecording = commands.mobileStopXctestScreenRecording; + mobileStartXctestScreenRecording = commands.xctestRecordScreenExtensions.mobileStartXctestScreenRecording; + mobileGetXctestScreenRecordingInfo = commands.xctestRecordScreenExtensions.mobileGetXctestScreenRecordingInfo; + mobileStopXctestScreenRecording = commands.xctestRecordScreenExtensions.mobileStopXctestScreenRecording; } /** From 1f6098128730aac4486a95e3e36917b8e79b8911 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 7 Mar 2024 18:33:01 +0100 Subject: [PATCH 6/6] typing --- lib/commands/xctest-record-screen.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/commands/xctest-record-screen.js b/lib/commands/xctest-record-screen.js index 20ee77679..50f302542 100644 --- a/lib/commands/xctest-record-screen.js +++ b/lib/commands/xctest-record-screen.js @@ -20,10 +20,11 @@ const SUBDIRECTORY = 'Attachments'; */ /** - * @typedef {XcTestScreenRecordingInfo} XcTestScreenRecording + * @typedef {Object} XcTestScreenRecordingType * @property {string} payload Base64-encoded content of the recorded media * file if `remotePath` parameter is empty or null or an empty string otherwise. * The media is expected to a be a valid QuickTime movie (.mov). + * @typedef {XcTestScreenRecordingInfo & XcTestScreenRecordingType} XcTestScreenRecording */ /** @@ -188,14 +189,15 @@ export default { this.log.debug(`Stopping the active screen recording: ${JSON.stringify(screenRecordingInfo)}`); await this.proxyCommand('/wda/video/stop', 'POST', {}); const videoPath = await retrieveXcTestScreenRecording.bind(this)(screenRecordingInfo.uuid); + const result = /** @type {XcTestScreenRecording} */ (screenRecordingInfo); try { - screenRecordingInfo.payload = await encodeBase64OrUpload(videoPath, remotePath, { + result.payload = await encodeBase64OrUpload(videoPath, remotePath, { user, pass, headers, fileFieldName, formFields, method }); } finally { await fs.rimraf(videoPath); } - return screenRecordingInfo; + return result; }, };