From 2104b7a9a58630ab7bf058f5db7990cc275cf588 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Tue, 9 Apr 2024 22:57:11 +0200 Subject: [PATCH 01/50] chore: Remove extra imports --- package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/package.json b/package.json index 3c8d01bae..1b66efd85 100644 --- a/package.json +++ b/package.json @@ -152,9 +152,6 @@ "@types/sinon": "^17.0.0", "@types/sinon-chai": "^3.2.9", "@types/teen_process": "^2.0.1", - "@typescript-eslint/eslint-plugin": "^6.9.0", - "@typescript-eslint/parser": "^6.9.0", - "appium": "^2.0.0", "axios": "^1.4.0", "chai": "^4.3.7", "chai-as-promised": "^7.1.1", From 336d096dcc17f8ab9f01d24d7ef0cc060c7838fc Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 9 Apr 2024 20:59:00 +0000 Subject: [PATCH 02/50] chore(release): 7.11.2 [skip ci] ## [7.11.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.1...v7.11.2) (2024-04-09) ### Miscellaneous Chores * Remove extra imports ([2104b7a](https://github.com/appium/appium-xcuitest-driver/commit/2104b7a9a58630ab7bf058f5db7990cc275cf588)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d624cb65f..326b9c9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.11.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.1...v7.11.2) (2024-04-09) + + +### Miscellaneous Chores + +* Remove extra imports ([2104b7a](https://github.com/appium/appium-xcuitest-driver/commit/2104b7a9a58630ab7bf058f5db7990cc275cf588)) + ## [7.11.1](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.0...v7.11.1) (2024-04-08) diff --git a/package.json b/package.json index 1b66efd85..4b61b7f68 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.11.1", + "version": "7.11.2", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 0c42d557d459f8ec25277dc1c2672a0045b16329 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 12 Apr 2024 08:17:56 +0200 Subject: [PATCH 03/50] fix: Tune appPushTimeout capability (#2384) --- docs/reference/capabilities.md | 2 +- lib/ios-fs-helpers.js | 12 ++++++++-- lib/real-device-management.js | 12 +++++++--- lib/real-device.js | 43 +++++++++++++++++++++------------- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index d652aaad0..df37bfb15 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -35,7 +35,7 @@ about capabilities, refer to the [Appium documentation](https://appium.io/docs/e | `appium:language` | Language to set for iOS app, for example `fr`. Please read [Language IDs](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPInternational/LanguageandLocaleIDs/LanguageandLocaleIDs.html) to get more details about available values for this capability. If a test is executed on a Simulator then UI language is changed as well. You can also change Simulator language in runtime using [mobile: configureLocalization](./execute-methods.md#mobile-configurelocalization) extension. | | `appium:locale` | Locale to set for iOS app, for example `fr_CA`. Please read [Locale IDs](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPInternational/LanguageandLocaleIDs/LanguageandLocaleIDs.html#//apple_ref/doc/uid/10000171i-CH15-SW9) to get more details about available values for this capability. If a test is executed on a Simulator then UI locale is changed as well. You can also change Simulator locale in runtime using [mobile: configureLocalization](./execute-methods.md#mobile-configurelocalization) extension. | | `appium:calendarFormat` | Calendar format to set for iOS Simulator, for example `gregorian` or `persian`. Can only be set in conjunction with `appium:locale`. | -| `appium:appPushTimeout` | The timeout for application upload in milliseconds. Works for real devices only. The default value is `30000`ms | +| `appium:appPushTimeout` | The timeout for an application install/upgrade in milliseconds. Works for real devices only. The default value is `480000` ms (8 minutes) | | `appium:appInstallStrategy` | Select application installation strategy for real devices. The following strategies are supported:
`serial` (default) - pushes app files to the device in a sequential order; this is the least performant strategy, although the most reliable
`parallel` - pushes app files simultaneously; this is usually the the most performant strategy, but sometimes could not be very stable
`ios-deploy` - tells the driver to use a third-party tool [ios-deploy](https://www.npmjs.com/package/ios-deploy) to install the app; obviously the tool must be installed separately first and must be present in PATH before it could be used. | | `appium:appTimeZone` | Defines the custom time zone override for the application under test. You can use UTC, PST, EST, as well as place-based timezone names such as America/Los_Angeles. The application must be (re)launched for the capability to take effect. See the [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for more details. The same behavior could be achieved by providing a custom value to the [TZ](https://developer.apple.com/forums/thread/86951#263395) environment variable via the `appium:processArguments` capability | UTC | diff --git a/lib/ios-fs-helpers.js b/lib/ios-fs-helpers.js index 248df25d2..0a12874e6 100644 --- a/lib/ios-fs-helpers.js +++ b/lib/ios-fs-helpers.js @@ -265,7 +265,7 @@ async function pushFolder(afcService, srcRootPath, dstRootPath, opts = {}) { readStream.on('error', onStreamError); }); readStream.pipe(writeStream); - await filePushPromise.timeout(timeoutMs); + await filePushPromise.timeout(Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000)); }; if (enableParallelPush) { @@ -276,17 +276,25 @@ async function pushFolder(afcService, srcRootPath, dstRootPath, opts = {}) { // keep the push queue filled if (pushPromises.length >= MAX_IO_CHUNK_SIZE) { await B.any(pushPromises); + const elapsedMs = timer.getDuration().asMilliSeconds; + if (elapsedMs > timeoutMs) { + throw new B.TimeoutError(`Timed out after ${elapsedMs} ms`); + } } _.remove(pushPromises, (p) => p.isFulfilled()); } if (!_.isEmpty(pushPromises)) { // handle the rest of push promises - await B.all(pushPromises); + await B.all(pushPromises).timeout(Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000)); } } else { log.debug(`Proceeding to serial files push`); for (const relativeFilePath of filesToPush) { await pushFile(relativeFilePath); + const elapsedMs = timer.getDuration().asMilliSeconds; + if (elapsedMs > timeoutMs) { + throw new B.TimeoutError(`Timed out after ${elapsedMs} ms`); + } } } diff --git a/lib/real-device-management.js b/lib/real-device-management.js index d8463634c..f394459c8 100644 --- a/lib/real-device-management.js +++ b/lib/real-device-management.js @@ -2,12 +2,14 @@ import _ from 'lodash'; import {buildSafariPreferences} from './app-utils'; import {utilities} from 'appium-ios-device'; +const DEFAULT_APP_INSTALLATION_TIMEOUT_MS = 8 * 60 * 1000; + /** * @typedef {Object} InstallOptions * * @property {boolean} [skipUninstall] Whether to skip app uninstall before installing it * @property {'serial'|'parallel'|'ios-deploy'} [strategy='serial'] One of possible install strategies ('serial', 'parallel', 'ios-deploy') - * @property {number} [timeout] App install timeout + * @property {number} [timeout=480000] App install timeout * @property {boolean} [shouldEnforceUninstall] Whether to enforce the app uninstallation. e.g. fullReset, or enforceAppInstall is true */ @@ -25,13 +27,17 @@ export async function installToRealDevice(app, bundleId, opts = {}) { return; } - const {skipUninstall, strategy, timeout} = opts; + const { + skipUninstall, + strategy, + timeout = DEFAULT_APP_INSTALLATION_TIMEOUT_MS, + } = opts; if (!skipUninstall) { this.log.info(`Reset requested. Removing app with id '${bundleId}' from the device`); await device.remove(bundleId); } - this.log.debug(`Installing '${app}' on device with UUID '${device.udid}'...`); + this.log.debug(`Installing '${app}' on the device with UUID '${device.udid}'...`); try { await device.install(app, timeout, strategy); diff --git a/lib/real-device.js b/lib/real-device.js index 7e9fee689..4127bc280 100644 --- a/lib/real-device.js +++ b/lib/real-device.js @@ -12,7 +12,6 @@ import { Devicectl } from './devicectl'; const APPLICATION_INSTALLED_NOTIFICATION = 'com.apple.mobile.application_installed'; const INSTALLATION_STAGING_DIR = 'PublicStaging'; const APPLICATION_NOTIFICATION_TIMEOUT_MS = 30 * 1000; -const IOS_DEPLOY_TIMEOUT_MS = 4 * 60 * 1000; const IOS_DEPLOY = 'ios-deploy'; const APP_INSTALL_STRATEGY = Object.freeze({ SERIAL: 'serial', @@ -27,6 +26,12 @@ export async function getConnectedDevices() { return await utilities.getConnectedDevices(); } +/** + * @typedef {Object} InstallOrUpgradeOptions + * @property {number} timeout Install/upgrade timeout in milliseconds + * @property {boolean} isUpgrade Whether it is an app upgrade or a new install + */ + export class RealDevice { /** * @param {string} udid @@ -67,7 +72,7 @@ export class RealDevice { /** * * @param {string} app - * @param {number} [timeout] + * @param {number} timeout * @param {'ios-deploy'|'serial'|'parallel'|null} strategy * @privateRemarks This really needs type guards built out */ @@ -93,9 +98,7 @@ export class RealDevice { throw new Error(`'${IOS_DEPLOY}' utility has not been found in PATH. Is it installed?`); } try { - await exec(IOS_DEPLOY, ['--id', this.udid, '--bundle', app], { - timeout: timeout ?? IOS_DEPLOY_TIMEOUT_MS, - }); + await exec(IOS_DEPLOY, ['--id', this.udid, '--bundle', app], {timeout}); } catch (err) { throw new Error(err.stderr || err.stdout || err.message); } @@ -106,22 +109,30 @@ export class RealDevice { await installWithIosDeploy(); } else { const afcService = await services.startAfcService(this.udid); + const enableParallelPush = _.toLower(/** @type {'parallel'} */ (strategy)) === APP_INSTALL_STRATEGY.PARALLEL; try { const bundleId = await extractBundleId(app); const bundlePathOnPhone = path.join(INSTALLATION_STAGING_DIR, bundleId); await pushFolder(afcService, app, bundlePathOnPhone, { + enableParallelPush, timeoutMs: timeout, - enableParallelPush: - _.toLower(/** @type {'parallel'} */ (strategy)) === APP_INSTALL_STRATEGY.PARALLEL, }); await this.installOrUpgradeApplication( bundlePathOnPhone, - await this.isAppInstalled(bundleId), + { + timeout: Math.max(timeout - timer.getDuration().asMilliSeconds, 60000), + isUpgrade: await this.isAppInstalled(bundleId), + } ); } catch (err) { this.log.warn(`Error installing app '${app}': ${err.message}`); if (err instanceof B.TimeoutError) { - this.log.warn(`Consider increasing the value of 'appPushTimeout' capability`); + this.log.info( + `Consider increasing the value of 'appPushTimeout' capability (the current value equals to ${timeout}ms)` + ); + if (!enableParallelPush) { + this.log.info(`Consider setting the value of 'appInstallStrategy' capability to 'parallel'`); + } } this.log.warn(`Falling back to '${IOS_DEPLOY}' usage`); try { @@ -140,9 +151,9 @@ export class RealDevice { /** * @param {string} bundlePathOnPhone - * @param {boolean} [isUpgrade=false] + * @param {InstallOrUpgradeOptions} opts */ - async installOrUpgradeApplication(bundlePathOnPhone, isUpgrade = false) { + async installOrUpgradeApplication(bundlePathOnPhone, {isUpgrade, timeout}) { const notificationService = await services.startNotificationProxyService(this.udid); const installationService = await services.startInstallationProxyService(this.udid); const appInstalledNotification = new B((resolve) => { @@ -153,11 +164,11 @@ export class RealDevice { const clientOptions = {PackageType: 'Developer'}; try { if (isUpgrade) { - this.log.debug(`An upgrade of the existing application is going to be performed`); - await installationService.upgradeApplication(bundlePathOnPhone, clientOptions); + this.log.debug(`An upgrade of the existing application is going to be performed. Will timeout in ${timeout} ms`); + await installationService.upgradeApplication(bundlePathOnPhone, clientOptions, timeout); } else { - this.log.debug(`A new application installation is going to be performed`); - await installationService.installApplication(bundlePathOnPhone, clientOptions); + this.log.debug(`A new application installation is going to be performed. Will timeout in ${timeout} ms`); + await installationService.installApplication(bundlePathOnPhone, clientOptions, timeout); } try { await appInstalledNotification.timeout( @@ -166,7 +177,7 @@ export class RealDevice { `${APPLICATION_NOTIFICATION_TIMEOUT_MS}ms but we will continue`, ); } catch (e) { - this.log.warn(`Failed to receive the notification. Error: ${e.message}`); + this.log.warn(e.message); } } finally { installationService.close(); From cb127fae3b4e6f05996ca9c0ee51a0d6b0c03c62 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 12 Apr 2024 06:19:43 +0000 Subject: [PATCH 04/50] chore(release): 7.11.3 [skip ci] ## [7.11.3](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.2...v7.11.3) (2024-04-12) ### Bug Fixes * Tune appPushTimeout capability ([#2384](https://github.com/appium/appium-xcuitest-driver/issues/2384)) ([0c42d55](https://github.com/appium/appium-xcuitest-driver/commit/0c42d557d459f8ec25277dc1c2672a0045b16329)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 326b9c9eb..6173af74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.11.3](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.2...v7.11.3) (2024-04-12) + + +### Bug Fixes + +* Tune appPushTimeout capability ([#2384](https://github.com/appium/appium-xcuitest-driver/issues/2384)) ([0c42d55](https://github.com/appium/appium-xcuitest-driver/commit/0c42d557d459f8ec25277dc1c2672a0045b16329)) + ## [7.11.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.1...v7.11.2) (2024-04-09) diff --git a/package.json b/package.json index 4b61b7f68..5fe88dc82 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.11.2", + "version": "7.11.3", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 090c615682bb82745174865982eb0bcc5e5b2922 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Mon, 15 Apr 2024 01:45:41 -0400 Subject: [PATCH 05/50] chore: deprecated useSimpleBuildTest, waitForQuiescence and calendarAccessAuthorized (#2383) --- docs/reference/capabilities.md | 4 ++-- lib/desired-caps.js | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index df37bfb15..87d3f8bd1 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -69,7 +69,7 @@ about capabilities, refer to the [Appium documentation](https://appium.io/docs/e |`appium:shouldUseSingletonTestManager`|Use default proxy for test management within `WebDriverAgent`. Setting this to `false` sometimes helps with socket hangup problems. Defaults to `true`.|`false`| |`appium:waitForIdleTimeout`|The amount of time in float seconds to wait until the application under test is idling. XCTest requires the app's main thread to be idling in order to execute any action on it, so WDA might not even start/freeze if the app under test is constantly hogging the main thread. The default value is `10` (seconds). Setting it to zero disables idling checks completely (not recommended) and has the same effect as setting `waitForQuiescence` to `false`. Available since Appium 1.20.0. |`1`| |`appium:useXctestrunFile`|Use Xctestrun file to launch WDA. It will search for such file in `bootstrapPath`. Expected name of file is `WebDriverAgentRunner_iphoneos-arm64.xctestrun` for real device and `WebDriverAgentRunner_iphonesimulator-x86_64.xctestrun` for simulator. One can do `build-for-testing` for `WebDriverAgent` project for simulator and real device and then you will see [Product Folder like this](./assets/images/useXctestrunFile.png) and you need to copy content of this folder at `bootstrapPath` location. Since this capability expects that you have already built `WDA` project, it neither checks whether you have necessary dependencies to build `WDA` nor will it try to build project. Defaults to `false`. _Tips: `Xcodebuild` builds for the target platform version. We'd recommend you to build with minimal OS version which you'd like to run as the original WDA module. e.g. If you build WDA for 12.2, the module cannot run on iOS 11.4 because of loading some module error on simulator. A module built with 11.4 can work on iOS 12.2. (This is xcodebuild's expected behaviour.)_ |`true`| -|`appium:useSimpleBuildTest`| Build with `build` and run test with `test` in xcodebuild for all Xcode version if this is `true`, or build with `build-for-testing` and run tests with `test-without-building` for over Xcode 8 if this is `false`. Defaults to `false`. | `true` or `false` | +| **Deprecated** `appium:useSimpleBuildTest`| Build with `build` and run test with `test` in xcodebuild for all Xcode version if this is `true`, or build with `build-for-testing` and run tests with `test-without-building` for over Xcode 8 if this is `false`. Defaults to `false`. | `true` or `false` | |`appium:wdaEventloopIdleDelay`|Delays the invocation of `-[XCUIApplicationProcess setEventLoopHasIdled:]` by the number of seconds specified with this capability. This can help quiescence apps that fail to do so for no obvious reason (and creating a session fails for that reason). This increases the time for session creation because `-[XCUIApplicationProcess setEventLoopHasIdled:]` is called multiple times. If you enable this capability start with at least `3` seconds and try increasing it, if creating the session still fails. Defaults to `0`. |`5`| |`appium:processArguments`|Process arguments and environment which will be sent to the `WebDriverAgent` server in a new session request. Please use [mobile: launchApp](./execute-methods.md#mobile-launchapp) to launch an application with process arguments in the middle of a session. |`{ args: ["a", "b", "c"] , env: { "a": "b", "c": "d" } }` or `'{"args": ["a", "b", "c"], "env": { "a": "b", "c": "d" }}'`| |`appium:autoLaunch`|When set to `false`, prevents the application under test from being launched automatically as a part of the new session startup process. The launch become the responsibility of the user. Defaults to `true`.|`true` or `false`| @@ -78,7 +78,7 @@ about capabilities, refer to the [Appium documentation](https://appium.io/docs/e |`appium:resultBundleVersion`| Specify the version of result bundle as `xcodebuild` argument for `WebDriverAgent` build. The default value depends on your Xcode version. Please read `man xcodebuild` for more details. | `/path/to/resultbundle` | |`appium:maxTypingFrequency`|Maximum frequency of keystrokes for typing and clear. If your tests are failing because of typing errors, you may want to adjust this. Defaults to 60 keystrokes per minute.|`30`| |`appium:simpleIsVisibleCheck`|Use native methods for determining visibility of elements. In some cases this takes a long time. Setting this capability to `false` will cause the system to use the position and size of elements to make sure they are visible on the screen. This can, however, lead to false results in some situations. Defaults to `false`. | `true`, `false`| -|`appium:waitForQuiescence`| It allows to turn on/off waiting for application quiescence in `WebDriverAgent`, while performing queries. The default value is `true`. You can avoid [this kind of issues](https://github.com/appium/appium/issues/11132) if you turn it off. Consider using `waitForIdleTimeout` capability instead for this purpose since Appium 1.20.0 | `false` | +| **Deprecated** `appium:waitForQuiescence`| It allows to turn on/off waiting for application quiescence in `WebDriverAgent`, while performing queries. The default value is `true`. You can avoid [this kind of issues](https://github.com/appium/appium/issues/11132) if you turn it off. Consider using `waitForIdleTimeout` capability instead for this purpose since Appium 1.20.0 | `false` | |`appium:mjpegServerPort`|The port number on which WDA broadcasts screenshots stream encoded into MJPEG format from the device under test. It might be necessary to change this value if the default port is busy because of other tests running in parallel. Default value: `9100`|`12000`| |`appium:screenshotQuality`| Changes the initial quality of display screenshots. This capability affects the screenshoting speed and the actual quality of resulting screenshots. Before version 5.4.0 of WebDriverAgent possible values were: `0`, `1` (default), `2`, where `0` abbreviates lossless PNG, `1` is a high-quality JPEG and `2` is a low-quality JPEG. In the version 5.4.0 one more mode has been added (`3`), which is now the default one. It abbreviates lossless HEIC with fallback to PNG if the device does not support hardware-accelerated HEIC encoding. You can also change the value of screenshotQuality in [settings](settings.md). | `2` | |`appium:autoAcceptAlerts`| Accept all iOS alerts automatically if they pop up. This includes privacy access permission alerts (location, contacts, photos). Default is `false`. |`true` or `false`| diff --git a/lib/desired-caps.js b/lib/desired-caps.js index f7e291b92..61876b64b 100644 --- a/lib/desired-caps.js +++ b/lib/desired-caps.js @@ -202,12 +202,15 @@ const desiredCapConstraints = /** @type {const} */ ({ }, calendarAccessAuthorized: { isBoolean: true, + deprecated: true }, useSimpleBuildTest: { isBoolean: true, + deprecated: true }, waitForQuiescence: { isBoolean: true, + deprecated: true }, maxTypingFrequency: { isNumber: true, From 7c0cc73c2c3ab823075fa1524ff6d81852f6df28 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 15 Apr 2024 05:47:35 +0000 Subject: [PATCH 06/50] chore(release): 7.11.4 [skip ci] ## [7.11.4](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.3...v7.11.4) (2024-04-15) ### Miscellaneous Chores * deprecated useSimpleBuildTest, waitForQuiescence and calendarAccessAuthorized ([#2383](https://github.com/appium/appium-xcuitest-driver/issues/2383)) ([090c615](https://github.com/appium/appium-xcuitest-driver/commit/090c615682bb82745174865982eb0bcc5e5b2922)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6173af74e..aea71c278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.11.4](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.3...v7.11.4) (2024-04-15) + + +### Miscellaneous Chores + +* deprecated useSimpleBuildTest, waitForQuiescence and calendarAccessAuthorized ([#2383](https://github.com/appium/appium-xcuitest-driver/issues/2383)) ([090c615](https://github.com/appium/appium-xcuitest-driver/commit/090c615682bb82745174865982eb0bcc5e5b2922)) + ## [7.11.3](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.2...v7.11.3) (2024-04-12) diff --git a/package.json b/package.json index 5fe88dc82..76ca4f29b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.11.3", + "version": "7.11.4", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 0c458437240ea2ab367e2aa2915aa053fb01481b Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Tue, 16 Apr 2024 21:12:10 -0400 Subject: [PATCH 07/50] feat: update Atoms in remtoe debugger to selenium 4.19.0 basis (#2385) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 76ca4f29b..57ed3bd95 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "appium-idb": "^1.6.13", "appium-ios-device": "^2.5.4", "appium-ios-simulator": "^6.1.2", - "appium-remote-debugger": "^11.0.0", + "appium-remote-debugger": "^11.1.0", "appium-webdriveragent": "^8.5.0", "appium-xcode": "^5.1.4", "async-lock": "^1.4.0", From 8f2fd5d791b5833bdf5bf29b68b1d5e43924f05f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 17 Apr 2024 01:13:56 +0000 Subject: [PATCH 08/50] chore(release): 7.12.0 [skip ci] ## [7.12.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.4...v7.12.0) (2024-04-17) ### Features * update Atoms in remtoe debugger to selenium 4.19.0 basis ([#2385](https://github.com/appium/appium-xcuitest-driver/issues/2385)) ([0c45843](https://github.com/appium/appium-xcuitest-driver/commit/0c458437240ea2ab367e2aa2915aa053fb01481b)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aea71c278..af43c8e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.12.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.4...v7.12.0) (2024-04-17) + + +### Features + +* update Atoms in remtoe debugger to selenium 4.19.0 basis ([#2385](https://github.com/appium/appium-xcuitest-driver/issues/2385)) ([0c45843](https://github.com/appium/appium-xcuitest-driver/commit/0c458437240ea2ab367e2aa2915aa053fb01481b)) + ## [7.11.4](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.3...v7.11.4) (2024-04-15) diff --git a/package.json b/package.json index 57ed3bd95..32a6dece9 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.11.4", + "version": "7.12.0", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 50749cfc11e39c34c8df9138a06539f865347082 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Thu, 18 Apr 2024 03:30:51 -0400 Subject: [PATCH 09/50] feat: add sendKeyStrategy for React to type input one by one in Web context (#2386) * feat: add sendKeyStrategy for React to type input one by one in Web context * tweak * Update capabilities.md * modify typehint * Update capabilities.md * tweak type * update caps --- docs/reference/capabilities.md | 1 + lib/commands/element.js | 20 +++++ lib/desired-caps.js | 3 + lib/driver.js | 1 + test/unit/commands/element-specs.js | 118 ++++++++++++++++++++-------- 5 files changed, 110 insertions(+), 33 deletions(-) diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 87d3f8bd1..1a45f4696 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -145,6 +145,7 @@ about capabilities, refer to the [Appium documentation](https://appium.io/docs/e |`appium:enablePerformanceLogging`| Enable Safari's performance logging (default `false`)| `true`, `false`| |`appium:autoWebview`| Move directly into Webview context if available. Default `false`|`true`, `false`| |`appium:skipTriggerInputEventAfterSendkeys`| If this capability is set to `true`, then whenever you call the Send Keys method in a web context, the driver will not fire an additional `input` event on the input field used for the call. This event, turned on by default, helps in situations where JS frameworks (like React) do not respond to the input events that occur by default when the underlying Selenium atom is executed. Default `false`|`true`, `false`| +|`appium:sendKeyStrategy`| If this capability is set to `oneByOne`, then whenever you call the Send Keys method in a web context, the driver will type each character the given string consists of in serial order to the element. This strategy helps in situations where JS frameworks (like React) update the view for each input. If `appium:skipTriggerInputEventAfterSendkeys` capability is `true`, it will affect every type. For example, when you are going to type the word `appium` with `oneByOne` strategy and `appium:skipTriggerInputEventAfterSendkeys` is enabled, the `appium:skipTriggerInputEventAfterSendkeys` option affects each typing action: `a`, `p`, `p`,`i`, `u` and `m`. Suppose any other value or no value has been provided to the `appium:sendKeyStrategy` capability. In that case, the driver types the given string in the destination input element. `appium` Send Keys input types `appium` if `oneByOne` was not set. |`oneByOne`| ### Other diff --git a/lib/commands/element.js b/lib/commands/element.js index e20e19064..e7815d848 100644 --- a/lib/commands/element.js +++ b/lib/commands/element.js @@ -249,7 +249,26 @@ const commands = { const atomsElement = this.getAtomsElement(el); await this.executeAtom('click', [atomsElement]); + + if (this.opts.sendKeyStrategy !== 'oneByOne') { + await this.setValueWithWebAtom(atomsElement, value); + return; + } + for (const char of prepareInputValue(value)) { + await this.setValueWithWebAtom(atomsElement, char); + } + }, + + /** + * Set value with Atom for Web. This method calls `type` atom only. + * Expected to be called as part of {@linkcode setValue}. + * @this {XCUITestDriver} + * @param {import('./types').AtomsElement} atomsElement A target element to type the given value. + * @param {string|string[]} value The actual text to type. + */ + async setValueWithWebAtom(atomsElement, value) { await this.executeAtom('type', [atomsElement, value]); + if (this.opts.skipTriggerInputEventAfterSendkeys) { return; } @@ -267,6 +286,7 @@ const commands = { const scriptAsString = `return (${triggerInputEvent}).apply(null, arguments)`; await this.executeAtom('execute_script', [scriptAsString, [atomsElement]]); }, + /** * Send keys to the app * @param {string[]} value - Array of keys to send diff --git a/lib/desired-caps.js b/lib/desired-caps.js index 61876b64b..c9aa3350c 100644 --- a/lib/desired-caps.js +++ b/lib/desired-caps.js @@ -364,6 +364,9 @@ const desiredCapConstraints = /** @type {const} */ ({ skipTriggerInputEventAfterSendkeys: { isBoolean: true, }, + sendKeyStrategy: { + isString: true, + }, skipSyncUiDialogTranslation: { isBoolean: true, }, diff --git a/lib/driver.js b/lib/driver.js index 34c094206..c0d8633e9 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -1951,6 +1951,7 @@ export class XCUITestDriver extends BaseDriver { /** @deprecated */ setValueImmediate = commands.elementExtensions.setValueImmediate; setValue = commands.elementExtensions.setValue; + setValueWithWebAtom = commands.elementExtensions.setValueWithWebAtom; keys = commands.elementExtensions.keys; clear = commands.elementExtensions.clear; getContentSize = commands.elementExtensions.getContentSize; diff --git a/test/unit/commands/element-specs.js b/test/unit/commands/element-specs.js index 803df62d8..2b8115160 100644 --- a/test/unit/commands/element-specs.js +++ b/test/unit/commands/element-specs.js @@ -244,49 +244,101 @@ describe('element commands', function () { }); describe('setValue', function () { - const elementId = 2; - const expectedEndpoint = `/element/${elementId}/value`; - const expectedMethod = 'POST'; - - describe('success', function () { - it('should proxy string as array of characters', async function () { - await driver.setValue('hello\uE006', elementId); - proxyStub.should.have.been.calledOnceWith(expectedEndpoint, expectedMethod, { - value: ['h', 'e', 'l', 'l', 'o', '\n'], + describe('Native contest', function () { + const elementId = 2; + const expectedEndpoint = `/element/${elementId}/value`; + const expectedMethod = 'POST'; + + describe('success', function () { + it('should proxy string as array of characters', async function () { + await driver.setValue('hello\uE006', elementId); + proxyStub.should.have.been.calledOnceWith(expectedEndpoint, expectedMethod, { + value: ['h', 'e', 'l', 'l', 'o', '\n'], + }); }); - }); - it('should proxy string with smileys as array of characters', async function () { - await driver.setValue('helloπŸ˜€πŸ˜Ž', elementId); - proxyStub.should.have.been.calledOnceWith(expectedEndpoint, expectedMethod, { - value: ['h', 'e', 'l', 'l', 'o', 'πŸ˜€', '😎'], + it('should proxy string with smileys as array of characters', async function () { + await driver.setValue('helloπŸ˜€πŸ˜Ž', elementId); + proxyStub.should.have.been.calledOnceWith(expectedEndpoint, expectedMethod, { + value: ['h', 'e', 'l', 'l', 'o', 'πŸ˜€', '😎'], + }); }); - }); - it('should proxy number as array of characters', async function () { - await driver.setValue(1234.56, elementId); - proxyStub.should.have.been.calledOnceWith(expectedEndpoint, expectedMethod, { - value: ['1', '2', '3', '4', '.', '5', '6'], + it('should proxy number as array of characters', async function () { + await driver.setValue(1234.56, elementId); + proxyStub.should.have.been.calledOnceWith(expectedEndpoint, expectedMethod, { + value: ['1', '2', '3', '4', '.', '5', '6'], + }); }); - }); - it('should proxy string array as array of characters', async function () { - await driver.setValue(['hel', 'lo'], elementId); - proxyStub.should.have.been.calledOnceWith(expectedEndpoint, expectedMethod, { - value: ['h', 'e', 'l', 'l', 'o'], + it('should proxy string array as array of characters', async function () { + await driver.setValue(['hel', 'lo'], elementId); + proxyStub.should.have.been.calledOnceWith(expectedEndpoint, expectedMethod, { + value: ['h', 'e', 'l', 'l', 'o'], + }); + }); + it('should proxy integer array as array of characters', async function () { + await driver.setValue([1234], elementId); + proxyStub.should.have.been.calledOnceWith(expectedEndpoint, expectedMethod, { + value: ['1', '2', '3', '4'], + }); }); }); - it('should proxy integer array as array of characters', async function () { - await driver.setValue([1234], elementId); - proxyStub.should.have.been.calledOnceWith(expectedEndpoint, expectedMethod, { - value: ['1', '2', '3', '4'], + + describe('failure', function () { + it('should throw invalid argument exception for null', async function () { + await driver.setValue(null, elementId).should.be.rejectedWith(/supported/); + }); + it('should throw invalid argument exception for object', async function () { + await driver.setValue({hi: 'there'}, elementId).should.be.rejectedWith(/supported/); }); }); }); - describe('failure', function () { - it('should throw invalid argument exception for null', async function () { - await driver.setValue(null, elementId).should.be.rejectedWith(/supported/); + describe('Web contest', function () { + const elementId = 2; + + /** @type {sinon.SinonStubbedMember} */ + let atomElement; + /** @type {sinon.SinonStubbedMember} */ + let executeAtom; + /** @type {sinon.SinonStubbedMember} */ + let setValueWithWebAtom; + const webEl = {ELEMENT: '5000', 'element-6066-11e4-a52e-4f735466cecf': '5000'}; + + beforeEach(function () { + driver.curContext = 'fake web context'; + atomElement = sandbox.stub(driver, 'getAtomsElement').returns(webEl); + executeAtom = sandbox.stub(driver, 'executeAtom'); + setValueWithWebAtom = sandbox.stub(driver, 'setValueWithWebAtom'); + }); + + afterEach(function () { + sandbox.restore(); }); - it('should throw invalid argument exception for object', async function () { - await driver.setValue({hi: 'there'}, elementId).should.be.rejectedWith(/supported/); + + describe('setValueWithWebAtom', function () { + it('with default', async function () { + driver.opts.sendKeyStrategy = undefined; + await driver.setValue('hello\uE006πŸ˜€', elementId); + atomElement.should.have.been.calledOnce; + executeAtom.should.have.been.calledOnce; + setValueWithWebAtom.should.have.been.calledOnceWith( + webEl, + 'hello\uE006πŸ˜€' + ); + }); + + it('with oneByOne', async function () { + driver.opts.sendKeyStrategy = 'oneByOne'; + await driver.setValue('hello\uE006πŸ˜€', elementId); + atomElement.should.have.been.calledOnce; + executeAtom.should.have.been.calledOnce; + setValueWithWebAtom.getCall(0).args.should.eql([webEl, 'h']); + setValueWithWebAtom.getCall(1).args.should.eql([webEl, 'e']); + setValueWithWebAtom.getCall(2).args.should.eql([webEl, 'l']); + setValueWithWebAtom.getCall(3).args.should.eql([webEl, 'l']); + setValueWithWebAtom.getCall(4).args.should.eql([webEl, 'o']); + setValueWithWebAtom.getCall(5).args.should.eql([webEl, '\n']); + setValueWithWebAtom.getCall(6).args.should.eql([webEl, 'πŸ˜€']); + }); }); }); }); From 9183b8eec3a2a844e3487539fc4922cd09b47229 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 18 Apr 2024 07:32:33 +0000 Subject: [PATCH 10/50] chore(release): 7.13.0 [skip ci] ## [7.13.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.12.0...v7.13.0) (2024-04-18) ### Features * add sendKeyStrategy for React to type input one by one in Web context ([#2386](https://github.com/appium/appium-xcuitest-driver/issues/2386)) ([50749cf](https://github.com/appium/appium-xcuitest-driver/commit/50749cfc11e39c34c8df9138a06539f865347082)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af43c8e4a..b7069cc29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.13.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.12.0...v7.13.0) (2024-04-18) + + +### Features + +* add sendKeyStrategy for React to type input one by one in Web context ([#2386](https://github.com/appium/appium-xcuitest-driver/issues/2386)) ([50749cf](https://github.com/appium/appium-xcuitest-driver/commit/50749cfc11e39c34c8df9138a06539f865347082)) + ## [7.12.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.11.4...v7.12.0) (2024-04-17) diff --git a/package.json b/package.json index 32a6dece9..591081fe9 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.12.0", + "version": "7.13.0", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From b04cebd99418b0e6d55d3c1813700779248e6541 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Tue, 23 Apr 2024 07:38:34 +0200 Subject: [PATCH 11/50] feat: Perform bundles extraction in stream (#2387) --- lib/app-utils.js | 253 +++++++++++++++++++++++++++++++++-- lib/driver.js | 110 +-------------- package.json | 2 +- test/unit/app-utils-specs.js | 91 +++++++++++++ 4 files changed, 340 insertions(+), 116 deletions(-) create mode 100644 test/unit/app-utils-specs.js diff --git a/lib/app-utils.js b/lib/app-utils.js index dda29ce53..f6dc02843 100644 --- a/lib/app-utils.js +++ b/lib/app-utils.js @@ -1,11 +1,13 @@ import _ from 'lodash'; import path from 'path'; -import {plist, fs, util, tempDir, zip} from 'appium/support'; +import {plist, fs, util, tempDir, zip, timing} from 'appium/support'; import log from './logger.js'; import {LRUCache} from 'lru-cache'; import os from 'node:os'; import {exec} from 'teen_process'; import B from 'bluebird'; +import {spawn} from 'node:child_process'; +import assert from 'node:assert'; const STRINGSDICT_RESOURCE = '.stringsdict'; const STRINGS_RESOURCE = '.strings'; @@ -22,6 +24,9 @@ const SAFARI_OPTS_ALIASES_MAP = /** @type {const} */ ({ safariIgnoreFraudWarning: [['WarnAboutFraudulentWebsites'], (x) => Number(!x)], safariOpenLinksInBackground: [['OpenLinksInBackground'], (x) => Number(Boolean(x))], }); +const MAX_ARCHIVE_SCAN_DEPTH = 1; +export const SUPPORTED_EXTENSIONS = [IPA_EXT, APP_EXT]; +const MACOS_RESOURCE_FOLDER = '__MACOSX'; /** @@ -261,27 +266,110 @@ export async function isAppBundle(appPath) { } /** - * Extract the given archive and looks for items with given extensions in it + * @typedef {Object} UnzipInfo + * @property {string} rootDir + * @property {number} archiveSize + */ + +/** + * Unzips a ZIP archive on the local file system. * * @param {string} archivePath Full path to a .zip archive - * @param {Array} appExtensions List of matching item extensions - * @returns {Promise<[string, string[]]>} Tuple, where the first element points to - * a temporary folder root where the archive has been extracted and the second item - * contains a list of relative paths to matched items + * @returns {Promise} temporary folder root where the archive has been extracted */ -export async function findApps(archivePath, appExtensions) { +export async function unzipFile(archivePath) { const useSystemUnzipEnv = process.env.APPIUM_PREFER_SYSTEM_UNZIP; const useSystemUnzip = _.isEmpty(useSystemUnzipEnv) || !['0', 'false'].includes(_.toLower(useSystemUnzipEnv)); const tmpRoot = await tempDir.openDir(); - await zip.extractAllTo(archivePath, tmpRoot, {useSystemUnzip}); + try { + await zip.extractAllTo(archivePath, tmpRoot, {useSystemUnzip}); + } catch (e) { + await fs.rimraf(tmpRoot); + throw e; + } + return { + rootDir: tmpRoot, + archiveSize: (await fs.stat(archivePath)).size, + }; +} + +/** + * Unzips a ZIP archive from a stream. + * Uses bdstar tool for this purpose. + * This allows to optimize the time needed to prepare the app under test + * to MAX(download, unzip) instead of SUM(download, unzip) + * + * @param {import('node:stream').Readable} zipStream + * @returns {Promise} + */ +export async function unzipStream(zipStream) { + const tmpRoot = await tempDir.openDir(); + const bsdtarProcess = spawn(await fs.which('bsdtar'), [ + '-x', + '--exclude', MACOS_RESOURCE_FOLDER, + '--exclude', `${MACOS_RESOURCE_FOLDER}/*`, + '-', + ], { + cwd: tmpRoot, + }); + let archiveSize = 0; + bsdtarProcess.stderr.on('data', (chunk) => { + const stderr = chunk.toString(); + if (_.trim(stderr)) { + log.warn(stderr); + } + }); + zipStream.on('data', (chunk) => { + archiveSize += _.size(chunk); + }); + zipStream.pipe(bsdtarProcess.stdin); + try { + await new B((resolve, reject) => { + zipStream.once('error', reject); + bsdtarProcess.once('exit', (code, signal) => { + zipStream.unpipe(bsdtarProcess.stdin); + log.debug(`bsdtar process exited with code ${code}, signal ${signal}`); + if (code === 0) { + resolve(); + } else { + reject(new Error('Is it a valid ZIP archive?')); + } + }); + bsdtarProcess.once('error', (e) => { + zipStream.unpipe(bsdtarProcess.stdin); + reject(e); + }); + }); + } catch (err) { + bsdtarProcess.kill(9); + await fs.rimraf(tmpRoot); + throw new Error(`The response data cannot be unzipped: ${err.message}`); + } finally { + bsdtarProcess.removeAllListeners(); + zipStream.removeAllListeners(); + } + return { + rootDir: tmpRoot, + archiveSize, + }; +} + +/** + * Looks for items with given extensions in the given folder + * + * @param {string} appPath Full path to an app bundle + * @param {Array} appExtensions List of matching item extensions + * @returns {Promise} List of relative paths to matched items + */ +async function findApps(appPath, appExtensions) { const globPattern = `**/*.+(${appExtensions.map((ext) => ext.replace(/^\./, '')).join('|')})`; const sortedBundleItems = ( await fs.glob(globPattern, { - cwd: tmpRoot, + cwd: appPath, }) ).sort((a, b) => a.split(path.sep).length - b.split(path.sep).length); - return [tmpRoot, sortedBundleItems]; + return sortedBundleItems; } /** @@ -318,3 +406,148 @@ export function buildSafariPreferences(opts) { } return safariSettings; } + +/** + * Unzip the given archive and find a matching .app bundle in it + * + * @this {import('./driver').XCUITestDriver} + * @param {string|import('node:stream').Readable} appPathOrZipStream The path to the archive. + * @param {number} depth [0] the current nesting depth. App bundles whose nesting level + * is greater than 1 are not supported. + * @returns {Promise} Full path to the first matching .app bundle.. + * @throws If no matching .app bundles were found in the provided archive. + */ +async function unzipApp(appPathOrZipStream, depth = 0) { + const errMsg = `The archive '${this.opts.app}' did not have any matching ${APP_EXT} or ${IPA_EXT} ` + + `bundles. Please make sure the provided package is valid and contains at least one matching ` + + `application bundle which is not nested.`; + if (depth > MAX_ARCHIVE_SCAN_DEPTH) { + throw new Error(errMsg); + } + + const timer = new timing.Timer().start(); + /** @type {string} */ + let rootDir; + /** @type {number} */ + let archiveSize; + try { + if (_.isString(appPathOrZipStream)) { + ({rootDir, archiveSize} = await unzipFile(appPathOrZipStream)); + } else { + if (depth > 0) { + assert.fail('Streaming unzip cannot be invoked for nested archive items'); + } + ({rootDir, archiveSize} = await unzipStream(appPathOrZipStream)); + } + } catch (e) { + this.log.debug(e.stack); + throw new Error( + `Cannot prepare the application at '${this.opts.app}' for testing. Original error: ${e.message}` + ); + } + const secondsElapsed = timer.getDuration().asSeconds; + this.log.info( + `The app '${this.opts.app}' (${util.toReadableSizeString(archiveSize)}) ` + + `has been ${_.isString(appPathOrZipStream) ? 'extracted' : 'downloaded and extracted'} ` + + `to '${rootDir}' in ${secondsElapsed.toFixed(3)}s` + ); + // it does not make much sense to approximate the speed for short downloads + if (secondsElapsed >= 1) { + const bytesPerSec = Math.floor(archiveSize / secondsElapsed); + this.log.debug(`Approximate decompression speed: ${util.toReadableSizeString(bytesPerSec)}/s`); + } + + const matchedPaths = await findApps(rootDir, SUPPORTED_EXTENSIONS); + if (_.isEmpty(matchedPaths)) { + this.log.debug(`'${path.basename(rootDir)}' has no bundles`); + } else { + this.log.debug( + `Found ${util.pluralize('bundle', matchedPaths.length, true)} in ` + + `'${path.basename(rootDir)}': ${matchedPaths}`, + ); + } + try { + for (const matchedPath of matchedPaths) { + const fullPath = path.join(rootDir, matchedPath); + if (await isAppBundle(fullPath)) { + const supportedPlatforms = await fetchSupportedAppPlatforms(fullPath); + if (this.isSimulator() && !supportedPlatforms.some((p) => _.includes(p, 'Simulator'))) { + this.log.info( + `'${matchedPath}' does not have Simulator devices in the list of supported platforms ` + + `(${supportedPlatforms.join(',')}). Skipping it`, + ); + continue; + } + if (this.isRealDevice() && !supportedPlatforms.some((p) => _.includes(p, 'OS'))) { + this.log.info( + `'${matchedPath}' does not have real devices in the list of supported platforms ` + + `(${supportedPlatforms.join(',')}). Skipping it`, + ); + continue; + } + this.log.info( + `'${matchedPath}' is the resulting application bundle selected from '${rootDir}'`, + ); + return await isolateAppBundle(fullPath); + } else if (_.endsWith(_.toLower(fullPath), IPA_EXT) && (await fs.stat(fullPath)).isFile()) { + try { + return await unzipApp.bind(this)(fullPath, depth + 1); + } catch (e) { + this.log.warn(`Skipping processing of '${matchedPath}': ${e.message}`); + } + } + } + } finally { + await fs.rimraf(rootDir); + } + throw new Error(errMsg); +} + +/** + * @this {import('./driver').XCUITestDriver} + * @param {import('@appium/types').DownloadAppOptions} opts + * @returns {Promise} + */ +export async function onDownloadApp({stream}) { + return await unzipApp.bind(this)(stream); +} + +/** + * @this {import('./driver').XCUITestDriver} + * @param {import('@appium/types').PostProcessOptions} opts + * @returns {Promise} + */ +export async function onPostConfigureApp({cachedAppInfo, isUrl, appPath}) { + // Pick the previously cached entry if its integrity has been preserved + /** @type {import('@appium/types').CachedAppInfo|undefined} */ + const appInfo = _.isPlainObject(cachedAppInfo) ? cachedAppInfo : undefined; + const cachedPath = appInfo ? /** @type {string} */ (appInfo.fullPath) : undefined; + if ( + // If cache is present + appInfo && cachedPath + // And if the path exists + && await fs.exists(cachedPath) + // And if hash matches to the cached one if this is a file + // Or count of files >= of the cached one if this is a folder + && ( + ((await fs.stat(cachedPath)).isFile() + && await fs.hash(cachedPath) === /** @type {any} */ (appInfo.integrity)?.file) + || (await fs.glob('**/*', {cwd: cachedPath})).length >= /** @type {any} */ ( + appInfo.integrity + )?.folder + ) + ) { + this.log.info(`Using '${cachedPath}' which was cached from '${appPath}'`); + return {appPath: cachedPath}; + } + + const isBundleAlreadyUnpacked = await isAppBundle(/** @type {string} */(appPath)); + // Only local .app bundles that are available in-place should not be cached + if (!isUrl && isBundleAlreadyUnpacked) { + return false; + } + // Cache the app while unpacking the bundle if necessary + return { + appPath: isBundleAlreadyUnpacked ? appPath : await unzipApp.bind(this)(/** @type {string} */(appPath)) + }; +} diff --git a/lib/driver.js b/lib/driver.js index c0d8633e9..e439a3295 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -12,15 +12,12 @@ import EventEmitter from 'node:events'; import path from 'node:path'; import url from 'node:url'; import { - APP_EXT, - IPA_EXT, + SUPPORTED_EXTENSIONS, SAFARI_BUNDLE_ID, extractBundleId, extractBundleVersion, - fetchSupportedAppPlatforms, - findApps, - isAppBundle, - isolateAppBundle, + onPostConfigureApp, + onDownloadApp, verifyApplicationPlatform, } from './app-utils'; import commands from './commands'; @@ -70,8 +67,6 @@ import { const SHUTDOWN_OTHER_FEAT_NAME = 'shutdown_other_sims'; const CUSTOMIZE_RESULT_BUNDLE_PATH = 'customize_result_bundle_path'; -const SUPPORTED_EXTENSIONS = [IPA_EXT, APP_EXT]; -const MAX_ARCHIVE_SCAN_DEPTH = 1; const defaultServerCaps = { webStorageEnabled: false, locationContextEnabled: false, @@ -1065,107 +1060,12 @@ export class XCUITestDriver extends BaseDriver { } this.opts.app = await this.helpers.configureApp(this.opts.app, { - onPostProcess: this.onPostConfigureApp.bind(this), + onPostProcess: onPostConfigureApp.bind(this), + onDownload: onDownloadApp.bind(this), supportedExtensions: SUPPORTED_EXTENSIONS, }); } - /** - * Unzip the given archive and find a matching .app bundle in it - * - * @param {string} appPath The path to the archive. - * @param {number} depth [0] the current nesting depth. App bundles whose nesting level - * is greater than 1 are not supported. - * @returns {Promise} Full path to the first matching .app bundle.. - * @throws If no matching .app bundles were found in the provided archive. - */ - async unzipApp(appPath, depth = 0) { - if (depth > MAX_ARCHIVE_SCAN_DEPTH) { - throw new Error('Nesting of package bundles is not supported'); - } - const [rootDir, matchedPaths] = await findApps(appPath, SUPPORTED_EXTENSIONS); - if (_.isEmpty(matchedPaths)) { - this.log.debug(`'${path.basename(appPath)}' has no bundles`); - } else { - this.log.debug( - `Found ${util.pluralize('bundle', matchedPaths.length, true)} in ` + - `'${path.basename(appPath)}': ${matchedPaths}`, - ); - } - try { - for (const matchedPath of matchedPaths) { - const fullPath = path.join(rootDir, matchedPath); - if (await isAppBundle(fullPath)) { - const supportedPlatforms = await fetchSupportedAppPlatforms(fullPath); - if (this.isSimulator() && !supportedPlatforms.some((p) => _.includes(p, 'Simulator'))) { - this.log.info( - `'${matchedPath}' does not have Simulator devices in the list of supported platforms ` + - `(${supportedPlatforms.join(',')}). Skipping it`, - ); - continue; - } - if (this.isRealDevice() && !supportedPlatforms.some((p) => _.includes(p, 'OS'))) { - this.log.info( - `'${matchedPath}' does not have real devices in the list of supported platforms ` + - `(${supportedPlatforms.join(',')}). Skipping it`, - ); - continue; - } - this.log.info( - `'${matchedPath}' is the resulting application bundle selected from '${appPath}'`, - ); - return await isolateAppBundle(fullPath); - } else if (_.endsWith(_.toLower(fullPath), IPA_EXT) && (await fs.stat(fullPath)).isFile()) { - try { - return await this.unzipApp(fullPath, depth + 1); - } catch (e) { - this.log.warn(`Skipping processing of '${matchedPath}': ${e.message}`); - } - } - } - } finally { - await fs.rimraf(rootDir); - } - throw new Error( - `${this.opts.app} did not have any matching ${APP_EXT} or ${IPA_EXT} ` + - `bundles. Please make sure the provided package is valid and contains at least one matching ` + - `application bundle which is not nested.`, - ); - } - - async onPostConfigureApp({cachedAppInfo, isUrl, appPath}) { - // Pick the previously cached entry if its integrity has been preserved - if ( - _.isPlainObject(cachedAppInfo) && - (await fs.stat(appPath)).isFile() && - (await fs.hash(appPath)) === cachedAppInfo.packageHash && - (await fs.exists(cachedAppInfo.fullPath)) && - ( - await fs.glob('**/*', { - cwd: cachedAppInfo.fullPath, - }) - ).length === cachedAppInfo.integrity.folder - ) { - this.log.info(`Using '${cachedAppInfo.fullPath}' which was cached from '${appPath}'`); - return {appPath: cachedAppInfo.fullPath}; - } - - // Only local .app bundles that are available in-place should not be cached - if (await isAppBundle(appPath)) { - return false; - } - - // Extract the app bundle and cache it - try { - return {appPath: await this.unzipApp(appPath)}; - } finally { - // Cleanup previously downloaded archive - if (isUrl) { - await fs.rimraf(appPath); - } - } - } - async determineDevice() { // in the one case where we create a sim, we will set this state this.lifecycleData.createSim = false; diff --git a/package.json b/package.json index 591081fe9..70b6d8c74 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,7 @@ "singleQuote": true }, "peerDependencies": { - "appium": "^2.4.1" + "appium": "^2.5.4" }, "devDependencies": { "@appium/docutils": "^1.0.2", diff --git a/test/unit/app-utils-specs.js b/test/unit/app-utils-specs.js new file mode 100644 index 000000000..fbbe49338 --- /dev/null +++ b/test/unit/app-utils-specs.js @@ -0,0 +1,91 @@ +import { + unzipStream, + unzipFile, +} from '../../lib/app-utils'; +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { fs, tempDir, zip } from 'appium/support'; +import path from 'node:path'; + +chai.should(); +chai.use(chaiAsPromised); + +describe('app-utils', function () { + describe('unzipStream', function () { + it('should unzip from stream', async function () { + try { + await fs.which('bsdtar'); + } catch (e) { + return; + } + + const tmpDir = await tempDir.openDir(); + let appRoot; + let srcStream; + try { + const tmpSrc = path.join(tmpDir, 'temp.zip'); + await zip.toArchive(tmpSrc, { + cwd: path.resolve(__dirname, '..', 'assets', 'biometric.app'), + }); + srcStream = fs.createReadStream(tmpSrc); + ({rootDir: appRoot} = await unzipStream(srcStream)); + await fs.exists(path.resolve(appRoot, 'Info.plist')).should.eventually.be.true; + } finally { + await fs.rimraf(tmpDir); + if (appRoot) { + await fs.rimraf(appRoot); + } + } + }); + + it('should fail for invalid archives', async function () { + try { + await fs.which('bsdtar'); + } catch (e) { + return; + } + + const tmpDir = await tempDir.openDir(); + let srcStream; + try { + const tmpSrc = path.join(tmpDir, 'Info.plist'); + await fs.copyFile(path.resolve(__dirname, '..', 'assets', 'biometric.app', 'Info.plist'), tmpSrc); + srcStream = fs.createReadStream(tmpSrc); + await unzipStream(srcStream).should.be.rejected; + } finally { + await fs.rimraf(tmpDir); + } + }); + }); + + describe('unzipFile', function () { + it('should unzip from file', async function () { + const tmpDir = await tempDir.openDir(); + let appRoot; + try { + const tmpSrc = path.join(tmpDir, 'temp.zip'); + await zip.toArchive(tmpSrc, { + cwd: path.resolve(__dirname, '..', 'assets', 'biometric.app'), + }); + ({rootDir: appRoot} = await unzipFile(tmpSrc)); + await fs.exists(path.resolve(appRoot, 'Info.plist')).should.eventually.be.true; + } finally { + await fs.rimraf(tmpDir); + if (appRoot) { + await fs.rimraf(appRoot); + } + } + }); + + it('should fail for invalid archives', async function () { + const tmpDir = await tempDir.openDir(); + try { + const tmpSrc = path.join(tmpDir, 'Info.plist'); + await fs.copyFile(path.resolve(__dirname, '..', 'assets', 'biometric.app', 'Info.plist'), tmpSrc); + await unzipFile(tmpSrc).should.be.rejected; + } finally { + await fs.rimraf(tmpDir); + } + }); + }); +}); From 30d110b40663b47ca6035fb64989e94a5280509e Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 23 Apr 2024 05:40:21 +0000 Subject: [PATCH 12/50] chore(release): 7.14.0 [skip ci] ## [7.14.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.13.0...v7.14.0) (2024-04-23) ### Features * Perform bundles extraction in stream ([#2387](https://github.com/appium/appium-xcuitest-driver/issues/2387)) ([b04cebd](https://github.com/appium/appium-xcuitest-driver/commit/b04cebd99418b0e6d55d3c1813700779248e6541)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7069cc29..b66e559a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.14.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.13.0...v7.14.0) (2024-04-23) + + +### Features + +* Perform bundles extraction in stream ([#2387](https://github.com/appium/appium-xcuitest-driver/issues/2387)) ([b04cebd](https://github.com/appium/appium-xcuitest-driver/commit/b04cebd99418b0e6d55d3c1813700779248e6541)) + ## [7.13.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.12.0...v7.13.0) (2024-04-18) diff --git a/package.json b/package.json index 70b6d8c74..0932f4bc0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.13.0", + "version": "7.14.0", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 520168aa7d8c230a44da136b9e8d21971c4ef8f8 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 26 Apr 2024 20:14:19 +0200 Subject: [PATCH 13/50] feat: Avoid unzipping of real device .ipa bundles (#2388) --- docs/reference/capabilities.md | 2 +- docs/reference/execute-methods.md | 2 +- lib/app-infos-cache.js | 159 +++++++++ lib/app-utils.js | 549 +++++++++++++++++------------ lib/commands/app-management.js | 29 +- lib/commands/app-strings.js | 9 +- lib/commands/file-movement.js | 2 +- lib/commands/types.ts | 1 + lib/desired-caps.js | 1 + lib/driver.js | 27 +- lib/execute-method-map.ts | 2 +- lib/ios-fs-helpers.js | 80 +++-- lib/real-device-management.js | 12 +- lib/real-device.js | 150 ++++---- test/unit/app-infos-cache-specs.js | 73 ++++ test/unit/driver-specs.js | 21 +- 16 files changed, 740 insertions(+), 379 deletions(-) create mode 100644 lib/app-infos-cache.js create mode 100644 test/unit/app-infos-cache-specs.js diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 1a45f4696..37b808f70 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -36,7 +36,7 @@ about capabilities, refer to the [Appium documentation](https://appium.io/docs/e | `appium:locale` | Locale to set for iOS app, for example `fr_CA`. Please read [Locale IDs](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPInternational/LanguageandLocaleIDs/LanguageandLocaleIDs.html#//apple_ref/doc/uid/10000171i-CH15-SW9) to get more details about available values for this capability. If a test is executed on a Simulator then UI locale is changed as well. You can also change Simulator locale in runtime using [mobile: configureLocalization](./execute-methods.md#mobile-configurelocalization) extension. | | `appium:calendarFormat` | Calendar format to set for iOS Simulator, for example `gregorian` or `persian`. Can only be set in conjunction with `appium:locale`. | | `appium:appPushTimeout` | The timeout for an application install/upgrade in milliseconds. Works for real devices only. The default value is `480000` ms (8 minutes) | -| `appium:appInstallStrategy` | Select application installation strategy for real devices. The following strategies are supported:
`serial` (default) - pushes app files to the device in a sequential order; this is the least performant strategy, although the most reliable
`parallel` - pushes app files simultaneously; this is usually the the most performant strategy, but sometimes could not be very stable
`ios-deploy` - tells the driver to use a third-party tool [ios-deploy](https://www.npmjs.com/package/ios-deploy) to install the app; obviously the tool must be installed separately first and must be present in PATH before it could be used. | +| **Deprecated** **Not used since v7.15.0** `appium:appInstallStrategy` | Select application installation strategy for real devices. The following strategies are supported:
`serial` (default) - pushes app files to the device in a sequential order; this is the least performant strategy, although the most reliable
`parallel` - pushes app files simultaneously; this is usually the the most performant strategy, but sometimes could not be very stable
`ios-deploy` - tells the driver to use a third-party tool [ios-deploy](https://www.npmjs.com/package/ios-deploy) to install the app; obviously the tool must be installed separately first and must be present in PATH before it could be used. | | `appium:appTimeZone` | Defines the custom time zone override for the application under test. You can use UTC, PST, EST, as well as place-based timezone names such as America/Los_Angeles. The application must be (re)launched for the capability to take effect. See the [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for more details. The same behavior could be achieved by providing a custom value to the [TZ](https://developer.apple.com/forums/thread/86951#263395) environment variable via the `appium:processArguments` capability | UTC | ### WebDriverAgent diff --git a/docs/reference/execute-methods.md b/docs/reference/execute-methods.md index c2ab955bd..bfadee830 100644 --- a/docs/reference/execute-methods.md +++ b/docs/reference/execute-methods.md @@ -166,7 +166,7 @@ Name | Type | Required | Description | Example --- | --- | --- | --- | --- app | string | yes | See the description of the `appium:app` capability | /path/to/my.app timeoutMs | number | no | The maximum time to wait until app install is finished in milliseconds on real devices. If not provided then the value of `appium:appPushTimeout` capability is used. If the capability is not provided then equals to 240000ms | 500000 -strategy | string | no | One of possible app installation strategies on real devices. This argument is ignored on simulators. If not provided then the value of `appium:appInstallStrategy` is used. If the latter is also not provided then `serial` is used. See the description of `appium:appInstallStrategy` capability for more details on available values. | parallel +**Deprecated** **Not Used since v7.15.0** strategy | string | no | One of possible app installation strategies on real devices. This argument is ignored on simulators. If not provided then the value of `appium:appInstallStrategy` is used. If the latter is also not provided then `serial` is used. See the description of `appium:appInstallStrategy` capability for more details on available values. | parallel checkVersion | bool | no | If set to `true`, it will make xcuitest driver to verify whether the app version currently installed on the device under test is older than the one, which is provided as `app` value. No app install is going to happen if the candidate app has the same or older version number than the already installed copy of it. The version number used for comparison must be provided as [CFBundleVersion](https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleversion) [Semantic Versioning](https://semver.org/)-compatible value in the application's `Info.plist`. No validation is performed and the `app` is installed if `checkVersion` was not provided or `false`, which is default behavior. | true ### mobile: isAppInstalled diff --git a/lib/app-infos-cache.js b/lib/app-infos-cache.js new file mode 100644 index 000000000..414a8be1c --- /dev/null +++ b/lib/app-infos-cache.js @@ -0,0 +1,159 @@ +import _ from 'lodash'; +import path from 'path'; +import {plist, fs, tempDir, zip} from 'appium/support'; +import {LRUCache} from 'lru-cache'; +import B from 'bluebird'; + +/** @type {LRUCache} */ +const MANIFEST_CACHE = new LRUCache({ + max: 40, + updateAgeOnHas: true, +}); +const MANIFEST_FILE_NAME = 'Info.plist'; +const MAX_MANIFEST_SIZE = 1024 * 1024; // 1 MiB + +export class AppInfosCache { + /** + * @param {import('@appium/types').AppiumLogger} log + */ + constructor (log) { + this.log = log; + } + + /** + * + * @param {string} appPath + * @param {string} propertyName + * @returns {Promise} + */ + async extractManifestProperty (appPath, propertyName) { + const result = (await this.put(appPath))[propertyName]; + this.log.debug(`${propertyName}: ${JSON.stringify(result)}`); + return result; + } + + /** + * + * @param {string} appPath + * @returns {Promise} + */ + async extractBundleId (appPath) { + return await this.extractManifestProperty(appPath, 'CFBundleIdentifier'); + } + + /** + * + * @param {string} appPath + * @returns {Promise} + */ + async extractBundleVersion (appPath) { + return await this.extractManifestProperty(appPath, 'CFBundleVersion'); + } + + /** + * + * @param {string} appPath + * @returns {Promise} + */ + async extractAppPlatforms (appPath) { + const result = await this.extractManifestProperty(appPath, 'CFBundleSupportedPlatforms'); + if (!Array.isArray(result)) { + throw new Error(`${path.basename(appPath)}': CFBundleSupportedPlatforms is not a valid list`); + } + return result; + } + + /** + * + * @param {string} appPath + * @returns {Promise} + */ + async extractExecutableName (appPath) { + return await this.extractManifestProperty(appPath, 'CFBundleExecutable'); + } + + /** + * + * @param {string} appPath Full path to the .ipa or .app bundle + * @returns {Promise} The payload of the manifest plist + * @throws {Error} If the given app is not a valid bundle + */ + async put (appPath) { + const readPlist = async (/** @type {string} */ plistPath) => { + try { + return await plist.parsePlistFile(plistPath); + } catch (e) { + this.log.debug(e.stack); + throw new Error(`Cannot parse ${MANIFEST_FILE_NAME} of '${appPath}'. Is it a valid application bundle?`); + } + }; + + if ((await fs.stat(appPath)).isFile()) { + /** @type {import('@appium/types').StringRecord|undefined} */ + let manifestPayload; + /** @type {Error|undefined} */ + let lastError; + try { + await zip.readEntries(appPath, async ({entry, extractEntryTo}) => { + if (!_.endsWith(entry.fileName, `.app/${MANIFEST_FILE_NAME}`)) { + return true; + } + + const hash = `${entry.crc32}`; + if (MANIFEST_CACHE.has(hash)) { + manifestPayload = MANIFEST_CACHE.get(hash); + return false; + } + const tmpRoot = await tempDir.openDir(); + try { + await extractEntryTo(tmpRoot); + const plistPath = path.resolve(tmpRoot, entry.fileName); + manifestPayload = await readPlist(plistPath); + if (entry.uncompressedSize <= MAX_MANIFEST_SIZE && _.isPlainObject(manifestPayload)) { + this.log.debug( + `Caching the manifest for ${manifestPayload?.CFBundleIdentifier} app ` + + `from an archived source using the key '${hash}'` + ); + MANIFEST_CACHE.set(hash, manifestPayload); + } + } catch (e) { + this.log.debug(e.stack); + lastError = e; + } finally { + await fs.rimraf(tmpRoot); + } + return false; + }); + } catch (e) { + this.log.debug(e.stack); + throw new Error(`Cannot find ${MANIFEST_FILE_NAME} in '${appPath}'. Is it a valid application bundle?`); + } + if (!manifestPayload) { + let errorMessage = `Cannot extract ${MANIFEST_FILE_NAME} from '${appPath}'. Is it a valid application bundle?`; + if (lastError) { + errorMessage += ` Original error: ${lastError.message}`; + } + throw new Error(errorMessage); + } + return manifestPayload; + } + + // appPath points to a folder + const manifestPath = path.join(appPath, MANIFEST_FILE_NAME); + const hash = await fs.hash(manifestPath); + if (MANIFEST_CACHE.has(hash)) { + return /** @type {import('@appium/types').StringRecord} */ (MANIFEST_CACHE.get(hash)); + } + const [payload, stat] = await B.all([ + readPlist(manifestPath), + fs.stat(manifestPath), + ]); + if (stat.size <= MAX_MANIFEST_SIZE && _.isPlainObject(payload)) { + this.log.debug( + `Caching the manifest for ${payload.CFBundleIdentifier} app from a file source using the key '${hash}'` + ); + MANIFEST_CACHE.set(hash, payload); + } + return payload; + } +} diff --git a/lib/app-utils.js b/lib/app-utils.js index f6dc02843..6eeeda671 100644 --- a/lib/app-utils.js +++ b/lib/app-utils.js @@ -2,20 +2,19 @@ import _ from 'lodash'; import path from 'path'; import {plist, fs, util, tempDir, zip, timing} from 'appium/support'; import log from './logger.js'; -import {LRUCache} from 'lru-cache'; import os from 'node:os'; import {exec} from 'teen_process'; import B from 'bluebird'; import {spawn} from 'node:child_process'; import assert from 'node:assert'; +import { isTvOs } from './utils.js'; const STRINGSDICT_RESOURCE = '.stringsdict'; const STRINGS_RESOURCE = '.strings'; export const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari'; export const APP_EXT = '.app'; export const IPA_EXT = '.ipa'; -/** @type {LRUCache} */ -const PLIST_CACHE = new LRUCache({max: 20}); +const ZIP_EXT = '.zip'; const SAFARI_OPTS_ALIASES_MAP = /** @type {const} */ ({ safariAllowPopups: [ ['WebKitJavaScriptCanOpenWindowsAutomatically', 'JavaScriptCanOpenWindowsAutomatically'], @@ -27,146 +26,64 @@ const SAFARI_OPTS_ALIASES_MAP = /** @type {const} */ ({ const MAX_ARCHIVE_SCAN_DEPTH = 1; export const SUPPORTED_EXTENSIONS = [IPA_EXT, APP_EXT]; const MACOS_RESOURCE_FOLDER = '__MACOSX'; - - -/** - * Retrieves the value of the given entry name from the application's Info.plist. - * - * @this {Object} Optinal instance used for caching. Ususally the driver instance. - * @param {string} app Full path to the app bundle root. - * @param {string} entryName Key name in the plist. - * @returns {Promise} Either the extracted value or undefined if no such key has been found in the plist. - * @throws {Error} If the application's Info.plist cannot be parsed. - */ -async function extractPlistEntry(app, entryName) { - const plistPath = path.resolve(app, 'Info.plist'); - - const parseFile = async () => { - try { - return await plist.parsePlistFile(plistPath); - } catch (err) { - throw new Error(`Could not extract Info.plist from '${path.basename(app)}': ${err.message}`); - } - }; - - let plistObj = PLIST_CACHE.get(app); - if (!plistObj) { - plistObj = await parseFile(); - PLIST_CACHE.set(app, plistObj); - } - return /** @type {import('@appium/types').StringRecord} */ (plistObj)[entryName]; -} - -/** - * - * @param {string} app - * @returns {Promise} - */ -export async function extractBundleId(app) { - const bundleId = await extractPlistEntry(app, 'CFBundleIdentifier'); - log.debug(`Getting bundle ID from app '${app}': '${bundleId}'`); - return bundleId; -} - -/** - * - * @param {string} app - * @returns {Promise} - */ -export async function extractBundleVersion(app) { - return await extractPlistEntry(app, 'CFBundleVersion'); -} - -/** - * - * @param {string} app - * @returns {Promise} - */ -async function extractExecutableName(app) { - return await extractPlistEntry(app, 'CFBundleExecutable'); -} - -/** - * - * @param {string} app - * @returns {Promise} - */ -export async function fetchSupportedAppPlatforms(app) { - try { - const result = await extractPlistEntry(app, 'CFBundleSupportedPlatforms'); - if (!_.isArray(result)) { - log.warn(`${path.basename(app)}': CFBundleSupportedPlatforms is not a valid list`); - return []; - } - return result; - } catch (err) { - log.warn( - `Cannot extract the list of supported platforms from '${path.basename(app)}': ${err.message}`, - ); - return []; - } -} - -/** - * @typedef {Object} PlatformOpts - * - * @property {boolean} isSimulator - Whether the destination platform is a Simulator - * @property {boolean} isTvOS - Whether the destination platform is a Simulator - */ +const SANITIZE_REPLACEMENT = '-'; /** * Verify whether the given application is compatible to the * platform where it is going to be installed and tested. * - * @param {string} app - The actual path to the application bundle - * @param {PlatformOpts} expectedPlatform + * @this {XCUITestDriver} + * @returns {Promise} * @throws {Error} If bundle architecture does not match the expected device architecture. */ -export async function verifyApplicationPlatform(app, expectedPlatform) { - log.debug('Verifying application platform'); - - const supportedPlatforms = await fetchSupportedAppPlatforms(app); - log.debug(`CFBundleSupportedPlatforms: ${JSON.stringify(supportedPlatforms)}`); +export async function verifyApplicationPlatform() { + this.log.debug('Verifying application platform'); - const {isSimulator, isTvOS} = expectedPlatform; + const supportedPlatforms = await this.appInfosCache.extractAppPlatforms(this.opts.app); + const isTvOS = isTvOs(this.opts.platformName); const prefix = isTvOS ? 'AppleTV' : 'iPhone'; - const suffix = isSimulator ? 'Simulator' : 'OS'; + const suffix = this.isSimulator() ? 'Simulator' : 'OS'; const dstPlatform = `${prefix}${suffix}`; - const appFileName = path.basename(app); if (!supportedPlatforms.includes(dstPlatform)) { throw new Error( `${ - isSimulator ? 'Simulator' : 'Real device' - } architecture is not supported by the '${appFileName}' application. ` + + this.isSimulator() ? 'Simulator' : 'Real device' + } architecture is not supported by the ${this.opts.bundleId} application. ` + `Make sure the correct deployment target has been selected for its compilation in Xcode.`, ); } - if (isSimulator) { - const executablePath = path.resolve(app, await extractExecutableName(app)); - const [resFile, resUname] = await B.all([ - exec('file', [executablePath]), - exec('uname', ['-m']), - ]); - const bundleExecutableInfo = _.trim(resFile.stdout); - log.debug(bundleExecutableInfo); - const arch = _.trim(resUname.stdout); - const isAppleSilicon = os.cpus()[0].model.includes('Apple'); - // We cannot run Simulator builds compiled for arm64 on Intel machines - // Rosetta allows only to run Intel ones on arm64 - if ( - !_.includes(bundleExecutableInfo, `executable ${arch}`) && - !(isAppleSilicon && _.includes(bundleExecutableInfo, 'executable x86_64')) - ) { - const bundleId = await extractBundleId(app); - throw new Error( - `The ${bundleId} application does not support the ${arch} Simulator ` + - `architecture:\n${bundleExecutableInfo}\n\n` + - `Please rebuild your application to support the ${arch} platform.`, - ); - } + if (this.isRealDevice()) { + return; + } + + const executablePath = path.resolve(this.opts.app, await this.appInfosCache.extractExecutableName(this.opts.app)); + const [resFile, resUname] = await B.all([ + exec('file', [executablePath]), + exec('uname', ['-m']), + ]); + const bundleExecutableInfo = _.trim(resFile.stdout); + this.log.debug(bundleExecutableInfo); + const arch = _.trim(resUname.stdout); + const isAppleSilicon = os.cpus()[0].model.includes('Apple'); + // We cannot run Simulator builds compiled for arm64 on Intel machines + // Rosetta allows only to run Intel ones on arm64 + if ( + !_.includes(bundleExecutableInfo, `executable ${arch}`) && + !(isAppleSilicon && _.includes(bundleExecutableInfo, 'executable x86_64')) + ) { + throw new Error( + `The ${this.opts.bundleId} application does not support the ${arch} Simulator ` + + `architecture:\n${bundleExecutableInfo}\n\n` + + `Please rebuild your application to support the ${arch} platform.`, + ); } } +/** + * + * @param {string} resourcePath + * @returns {Promise} + */ async function readResource(resourcePath) { const data = await plist.parsePlistFile(resourcePath); const result = {}; @@ -176,79 +93,113 @@ async function readResource(resourcePath) { return result; } -export async function parseLocalizableStrings(opts) { - const {app, language = 'en', localizableStringsDir, stringFile, strictMode} = opts; +/** + * @typedef {Object} LocalizableStringsOptions + * @property {string} [app] + * @property {string} [language='en'] + * @property {string} [localizableStringsDir] + * @property {string} [stringFile] + * @property {boolean} [strictMode] + */ +/** + * Extracts string resources from an app + * + * @this {XCUITestDriver} + * @param {LocalizableStringsOptions} opts + * @returns {Promise} + */ +export async function parseLocalizableStrings(opts = {}) { + const {app, language = 'en', localizableStringsDir, stringFile, strictMode} = opts; if (!app) { const message = `Strings extraction is not supported if 'app' capability is not set`; if (strictMode) { throw new Error(message); } - log.info(message); + this.log.info(message); return {}; } - let lprojRoot; - for (const subfolder of [`${language}.lproj`, localizableStringsDir, '']) { - lprojRoot = path.resolve(app, subfolder); - if (await fs.exists(lprojRoot)) { - break; - } - const message = `No '${lprojRoot}' resources folder has been found`; - if (strictMode) { - throw new Error(message); + let bundleRoot = app; + const isArchive = (await fs.stat(app)).isFile(); + let tmpRoot; + try { + if (isArchive) { + tmpRoot = await tempDir.openDir(); + this.log.info(`Extracting '${app}' into a temporary location to parse its resources`); + await zip.extractAllTo(app, tmpRoot); + const relativeBundleRoot = /** @type {string} */ (_.first(await findApps(tmpRoot, [APP_EXT]))); + this.log.info(`Selecting '${relativeBundleRoot}'`); + bundleRoot = path.join(tmpRoot, relativeBundleRoot); } - log.debug(message); - } - log.info(`Will extract resource strings from '${lprojRoot}'`); - const resourcePaths = []; - if (stringFile) { - const dstPath = path.resolve(String(lprojRoot), stringFile); - if (await fs.exists(dstPath)) { - resourcePaths.push(dstPath); - } else { - const message = `No '${dstPath}' resource file has been found for '${app}'`; + /** @type {string|undefined} */ + let lprojRoot; + for (const subfolder of [`${language}.lproj`, localizableStringsDir, ''].filter(_.isString)) { + lprojRoot = path.resolve(bundleRoot, /** @type {string} */ (subfolder)); + if (await fs.exists(lprojRoot)) { + break; + } + const message = `No '${lprojRoot}' resources folder has been found`; if (strictMode) { throw new Error(message); } - log.info(message); - log.info(`Getting all the available strings from '${lprojRoot}'`); + this.log.debug(message); + } + if (!lprojRoot) { + return {}; } - } - if (_.isEmpty(resourcePaths) && (await fs.exists(String(lprojRoot)))) { - const resourceFiles = (await fs.readdir(String(lprojRoot))) - .filter((name) => _.some([STRINGS_RESOURCE, STRINGSDICT_RESOURCE], (x) => name.endsWith(x))) - .map((name) => path.resolve(lprojRoot, name)); - resourcePaths.push(...resourceFiles); - } - log.info(`Got ${resourcePaths.length} resource file(s) in '${lprojRoot}'`); + this.log.info(`Retrieving resource strings from '${lprojRoot}'`); + const resourcePaths = []; + if (stringFile) { + const dstPath = path.resolve(/** @type {string} */ (lprojRoot), stringFile); + if (await fs.exists(dstPath)) { + resourcePaths.push(dstPath); + } else { + const message = `No '${dstPath}' resource file has been found for '${app}'`; + if (strictMode) { + throw new Error(message); + } + this.log.info(message); + } + } - if (_.isEmpty(resourcePaths)) { - return {}; - } + if (_.isEmpty(resourcePaths) && (await fs.exists(lprojRoot))) { + const resourceFiles = (await fs.readdir(lprojRoot)) + .filter((name) => _.some([STRINGS_RESOURCE, STRINGSDICT_RESOURCE], (x) => name.endsWith(x))) + .map((name) => path.resolve(lprojRoot, name)); + resourcePaths.push(...resourceFiles); + } + this.log.info(`Got ${util.pluralize('resource file', resourcePaths.length, true)} in '${lprojRoot}'`); - const resultStrings = {}; - const toAbsolutePath = function (p) { - return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p); - }; - for (const resourcePath of resourcePaths) { - if (!util.isSubPath(toAbsolutePath(resourcePath), toAbsolutePath(app))) { - // security precaution - throw new Error(`'${resourcePath}' is expected to be located under '${app}'`); + if (_.isEmpty(resourcePaths)) { + return {}; } - try { - const data = await readResource(resourcePath); - log.debug(`Parsed ${_.keys(data).length} string(s) from '${resourcePath}'`); - _.merge(resultStrings, data); - } catch (e) { - log.warn(`Cannot parse '${resourcePath}' resource. Original error: ${e.message}`); + + const resultStrings = {}; + const toAbsolutePath = (/** @type {string} */ p) => path.isAbsolute(p) ? p : path.resolve(process.cwd(), p); + for (const resourcePath of resourcePaths) { + if (!util.isSubPath(toAbsolutePath(resourcePath), toAbsolutePath(bundleRoot))) { + // security precaution + throw new Error(`'${resourcePath}' is expected to be located under '${bundleRoot}'`); + } + try { + const data = await readResource(resourcePath); + this.log.debug(`Parsed ${util.pluralize('string', _.keys(data).length, true)} from '${resourcePath}'`); + _.merge(resultStrings, data); + } catch (e) { + this.log.warn(`Cannot parse '${resourcePath}' resource. Original error: ${e.message}`); + } } - } - log.info(`Got ${_.keys(resultStrings).length} string(s) from '${lprojRoot}'`); - return resultStrings; + this.log.info(`Retrieved ${util.pluralize('string', _.keys(resultStrings).length, true)} from '${lprojRoot}'`); + return resultStrings; + } finally { + if (tmpRoot) { + await fs.rimraf(tmpRoot); + } + } } /** @@ -257,7 +208,7 @@ export async function parseLocalizableStrings(opts) { * @param {string} appPath Possible .app bundle root * @returns {Promise} Whether the given path points to an .app bundle */ -export async function isAppBundle(appPath) { +async function isAppBundle(appPath) { return ( _.endsWith(_.toLower(appPath), APP_EXT) && (await fs.stat(appPath)).isDirectory() && @@ -265,6 +216,16 @@ export async function isAppBundle(appPath) { ); } +/** + * Check whether the given path on the file system points to the .ipa file + * + * @param {string} appPath Possible .ipa file + * @returns {Promise} Whether the given path points to an .ipa bundle + */ +async function isIpaBundle(appPath) { + return _.endsWith(_.toLower(appPath), IPA_EXT) && (await fs.stat(appPath)).isFile(); +} + /** * @typedef {Object} UnzipInfo * @property {string} rootDir @@ -355,6 +316,111 @@ export async function unzipStream(zipStream) { }; } +/** + * Used to parse the file name value from response headers + * + * @param {import('@appium/types').HTTPHeaders} headers + * @returns {string?} + */ +function parseFileName(headers) { + const contentDisposition = headers['content-disposition']; + if (!_.isString(contentDisposition)) { + return null; + } + + if (/^attachment/i.test(/** @type {string} */ (contentDisposition))) { + const match = /filename="([^"]+)/i.exec(/** @type {string} */ (contentDisposition)); + if (match) { + return fs.sanitizeName(match[1], {replacement: SANITIZE_REPLACEMENT}); + } + } + return null; +} + +/** + * Downloads and verifies remote applications for real devices + * + * @this {XCUITestDriver} + * @param {import('node:stream').Readable} stream + * @param {import('@appium/types').HTTPHeaders} headers + * @returns {Promise} + */ +async function downloadIpa(stream, headers) { + const timer = new timing.Timer().start(); + + const logPerformance = (/** @type {string} */ dstPath, /** @type {number} */ fileSize, /** @type {string} */ action) => { + const secondsElapsed = timer.getDuration().asSeconds; + this.log.info( + `The remote file (${util.toReadableSizeString(fileSize)}) ` + + `has been ${action} to '${dstPath}' in ${secondsElapsed.toFixed(3)}s` + ); + if (secondsElapsed >= 1) { + const bytesPerSec = Math.floor(fileSize / secondsElapsed); + this.log.debug(`Approximate speed: ${util.toReadableSizeString(bytesPerSec)}/s`); + } + }; + + // Check if the file to be downloaded is a .zip rather than .ipa + const fileName = parseFileName(headers) ?? `appium-app-${new Date().getTime()}${IPA_EXT}`; + if (fileName.toLowerCase().endsWith(ZIP_EXT)) { + const {rootDir, archiveSize} = await unzipStream(stream); + logPerformance(rootDir, archiveSize, 'downloaded and unzipped'); + try { + const matchedPaths = await findApps(rootDir, [IPA_EXT]); + if (!_.isEmpty(matchedPaths)) { + this.log.debug( + `Found ${util.pluralize(`${IPA_EXT} applicaition`, matchedPaths.length, true)} in ` + + `'${path.basename(rootDir)}': ${matchedPaths}` + ); + } + for (const matchedPath of matchedPaths) { + try { + await this.appInfosCache.put(matchedPath); + } catch (e) { + this.log.info(e.message); + continue; + } + this.log.debug(`Selecting the application at '${matchedPath}'`); + const isolatedPath = path.join(await tempDir.openDir(), path.basename(matchedPath)); + await fs.mv(matchedPath, isolatedPath); + return isolatedPath; + } + throw new Error(`The remote archive does not contain any valid ${IPA_EXT} applications`); + } finally { + await fs.rimraf(rootDir); + } + } + + const ipaPath = await tempDir.path({ + prefix: fileName, + suffix: fileName.toLowerCase().endsWith(IPA_EXT) ? '' : IPA_EXT, + }); + try { + const writer = fs.createWriteStream(ipaPath); + stream.pipe(writer); + + await new B((resolve, reject) => { + stream.once('error', reject); + writer.once('finish', resolve); + writer.once('error', (e) => { + stream.unpipe(writer); + reject(e); + }); + }); + } catch (err) { + throw new Error(`Cannot fetch the remote file: ${err.message}`); + } + const {size} = await fs.stat(ipaPath); + logPerformance(ipaPath, size, 'downloaded'); + try { + await this.appInfosCache.put(ipaPath); + } catch (e) { + await fs.rimraf(ipaPath); + throw e; + } + return ipaPath; +} + /** * Looks for items with given extensions in the given folder * @@ -375,15 +441,25 @@ async function findApps(appPath, appExtensions) { /** * Moves the application bundle to a newly created temporary folder * - * @param {string} appRoot Full path to the .app bundle + * @param {string} appPath Full path to the .app or .ipa bundle * @returns {Promise} The new path to the app bundle. - * The name of the app bundle remains though + * The name of the app bundle remains the same */ -export async function isolateAppBundle(appRoot) { +async function isolateApp(appPath) { + const appFileName = path.basename(appPath); + if ((await fs.stat(appPath)).isFile()) { + const isolatedPath = await tempDir.path({ + prefix: appFileName, + suffix: '', + }); + await fs.mv(appPath, isolatedPath, {mkdirp: true}); + return isolatedPath; + } + const tmpRoot = await tempDir.openDir(); - const dstRoot = path.join(tmpRoot, path.basename(appRoot)); - await fs.mv(appRoot, dstRoot, {mkdirp: true}); - return dstRoot; + const isolatedRoot = path.join(tmpRoot, appFileName); + await fs.mv(appPath, isolatedRoot, {mkdirp: true}); + return isolatedRoot; } /** @@ -410,7 +486,7 @@ export function buildSafariPreferences(opts) { /** * Unzip the given archive and find a matching .app bundle in it * - * @this {import('./driver').XCUITestDriver} + * @this {XCUITestDriver} * @param {string|import('node:stream').Readable} appPathOrZipStream The path to the archive. * @param {number} depth [0] the current nesting depth. App bundles whose nesting level * is greater than 1 are not supported. @@ -418,7 +494,7 @@ export function buildSafariPreferences(opts) { * @throws If no matching .app bundles were found in the provided archive. */ async function unzipApp(appPathOrZipStream, depth = 0) { - const errMsg = `The archive '${this.opts.app}' did not have any matching ${APP_EXT} or ${IPA_EXT} ` + + const errMsg = `The archive did not have any matching ${APP_EXT} or ${IPA_EXT} ` + `bundles. Please make sure the provided package is valid and contains at least one matching ` + `application bundle which is not nested.`; if (depth > MAX_ARCHIVE_SCAN_DEPTH) { @@ -432,22 +508,24 @@ async function unzipApp(appPathOrZipStream, depth = 0) { let archiveSize; try { if (_.isString(appPathOrZipStream)) { - ({rootDir, archiveSize} = await unzipFile(appPathOrZipStream)); + ({rootDir, archiveSize} = await unzipFile(/** @type {string} */ (appPathOrZipStream))); } else { if (depth > 0) { assert.fail('Streaming unzip cannot be invoked for nested archive items'); } - ({rootDir, archiveSize} = await unzipStream(appPathOrZipStream)); + ({rootDir, archiveSize} = await unzipStream( + /** @type {import('node:stream').Readable} */ (appPathOrZipStream)) + ); } } catch (e) { this.log.debug(e.stack); throw new Error( - `Cannot prepare the application at '${this.opts.app}' for testing. Original error: ${e.message}` + `Cannot prepare the application for testing. Original error: ${e.message}` ); } const secondsElapsed = timer.getDuration().asSeconds; this.log.info( - `The app '${this.opts.app}' (${util.toReadableSizeString(archiveSize)}) ` + + `The file (${util.toReadableSizeString(archiveSize)}) ` + `has been ${_.isString(appPathOrZipStream) ? 'extracted' : 'downloaded and extracted'} ` + `to '${rootDir}' in ${secondsElapsed.toFixed(3)}s` ); @@ -457,6 +535,31 @@ async function unzipApp(appPathOrZipStream, depth = 0) { this.log.debug(`Approximate decompression speed: ${util.toReadableSizeString(bytesPerSec)}/s`); } + const isCompatibleWithCurrentPlatform = async (/** @type {string} */ appPath) => { + let platforms; + try { + platforms = await this.appInfosCache.extractAppPlatforms(appPath); + } catch (e) { + this.log.info(e.message); + return false; + } + if (this.isSimulator() && !platforms.some((p) => _.includes(p, 'Simulator'))) { + this.log.info( + `'${appPath}' does not have Simulator devices in the list of supported platforms ` + + `(${platforms.join(',')}). Skipping it` + ); + return false; + } + if (this.isRealDevice() && !platforms.some((p) => _.includes(p, 'OS'))) { + this.log.info( + `'${appPath}' does not have real devices in the list of supported platforms ` + + `(${platforms.join(',')}). Skipping it` + ); + return false; + } + return true; + }; + const matchedPaths = await findApps(rootDir, SUPPORTED_EXTENSIONS); if (_.isEmpty(matchedPaths)) { this.log.debug(`'${path.basename(rootDir)}' has no bundles`); @@ -469,32 +572,12 @@ async function unzipApp(appPathOrZipStream, depth = 0) { try { for (const matchedPath of matchedPaths) { const fullPath = path.join(rootDir, matchedPath); - if (await isAppBundle(fullPath)) { - const supportedPlatforms = await fetchSupportedAppPlatforms(fullPath); - if (this.isSimulator() && !supportedPlatforms.some((p) => _.includes(p, 'Simulator'))) { - this.log.info( - `'${matchedPath}' does not have Simulator devices in the list of supported platforms ` + - `(${supportedPlatforms.join(',')}). Skipping it`, - ); - continue; - } - if (this.isRealDevice() && !supportedPlatforms.some((p) => _.includes(p, 'OS'))) { - this.log.info( - `'${matchedPath}' does not have real devices in the list of supported platforms ` + - `(${supportedPlatforms.join(',')}). Skipping it`, - ); - continue; - } - this.log.info( - `'${matchedPath}' is the resulting application bundle selected from '${rootDir}'`, - ); - return await isolateAppBundle(fullPath); - } else if (_.endsWith(_.toLower(fullPath), IPA_EXT) && (await fs.stat(fullPath)).isFile()) { - try { - return await unzipApp.bind(this)(fullPath, depth + 1); - } catch (e) { - this.log.warn(`Skipping processing of '${matchedPath}': ${e.message}`); - } + if ( + (await isAppBundle(fullPath) || (this.isRealDevice() && await isIpaBundle(fullPath))) + && await isCompatibleWithCurrentPlatform(fullPath) + ) { + this.log.debug(`Selecting the application at '${matchedPath}'`); + return await isolateApp(fullPath); } } } finally { @@ -504,16 +587,24 @@ async function unzipApp(appPathOrZipStream, depth = 0) { } /** - * @this {import('./driver').XCUITestDriver} + * The callback invoked by configureApp helper + * when it is necessary to download the remote application. + * We assume the remote file could be anythingm, but only + * .zip and .ipa formats are supported. + * A .zip archive can contain one or more + * + * @this {XCUITestDriver} * @param {import('@appium/types').DownloadAppOptions} opts * @returns {Promise} */ -export async function onDownloadApp({stream}) { - return await unzipApp.bind(this)(stream); +export async function onDownloadApp({stream, headers}) { + return this.isRealDevice() + ? await downloadIpa.bind(this)(stream, headers) + : await unzipApp.bind(this)(stream); } /** - * @this {import('./driver').XCUITestDriver} + * @this {XCUITestDriver} * @param {import('@appium/types').PostProcessOptions} opts * @returns {Promise} */ @@ -541,13 +632,25 @@ export async function onPostConfigureApp({cachedAppInfo, isUrl, appPath}) { return {appPath: cachedPath}; } - const isBundleAlreadyUnpacked = await isAppBundle(/** @type {string} */(appPath)); - // Only local .app bundles that are available in-place should not be cached - if (!isUrl && isBundleAlreadyUnpacked) { + const isLocalIpa = await isIpaBundle(/** @type {string} */(appPath)); + const isLocalApp = !isLocalIpa && await isAppBundle(/** @type {string} */(appPath)); + const isPackageReadyForInstall = isLocalApp || (this.isRealDevice() && isLocalIpa); + if (isPackageReadyForInstall) { + await this.appInfosCache.put(/** @type {string} */(appPath)); + } + // Only local .app bundles (real device/Simulator) + // and .ipa packages for real devices should not be cached + if (!isUrl && isPackageReadyForInstall) { return false; } // Cache the app while unpacking the bundle if necessary return { - appPath: isBundleAlreadyUnpacked ? appPath : await unzipApp.bind(this)(/** @type {string} */(appPath)) + appPath: isPackageReadyForInstall + ? appPath + : await unzipApp.bind(this)(/** @type {string} */(appPath)) }; } + +/** + * @typedef {import('./driver').XCUITestDriver} XCUITestDriver + */ diff --git a/lib/commands/app-management.js b/lib/commands/app-management.js index 2d216656e..d8882d940 100644 --- a/lib/commands/app-management.js +++ b/lib/commands/app-management.js @@ -4,7 +4,11 @@ import {errors} from 'appium/driver'; import {services} from 'appium-ios-device'; import path from 'node:path'; import B from 'bluebird'; -import { extractBundleId } from '../app-utils'; +import { + SUPPORTED_EXTENSIONS, + onPostConfigureApp, + onDownloadApp, +} from '../app-utils'; export default { /** @@ -12,15 +16,20 @@ export default { * * Please ensure the app is built for a correct architecture and is signed with a proper developer signature (for real devices) prior to calling this. * @param {string} app - See docs for `appium:app` capability - * @param {import('./types').AppInstallStrategy} [strategy] - One of possible app installation strategies on real devices. This argument is ignored on simulators. If not provided, then the value of `appium:appInstallStrategy` is used. If the latter is also not provided, then `serial` is used. See the description of `appium:appInstallStrategy` capability for more details on allowed values. - * @param {number} [timeoutMs] - The maximum time to wait until app install is finished (in ms) on real devices. If not provided, then the value of `appium:appPushTimeout` capability is used. If the capability is not provided then the default is 240000ms (4 minutes). - * @param {boolean} [checkVersion] - If the application installation follows currently installed application's version status if provided. No checking occurs if no this option. + * @param {number} [timeoutMs] - The maximum time to wait until app install is finished (in ms) on real devices. + * If not provided, then the value of `appium:appPushTimeout` capability is used. If the capability is not provided then the default is 240000ms (4 minutes). + * @param {boolean} [checkVersion] - If the application installation follows currently installed application's version status if provided. + * No checking occurs if no this option. * @privateRemarks Link to capability docs * @returns {Promise} * @this {XCUITestDriver} */ - async mobileInstallApp(app, timeoutMs, strategy, checkVersion) { - const srcAppPath = await this.helpers.configureApp(app, '.app'); + async mobileInstallApp(app, timeoutMs, checkVersion) { + const srcAppPath = await this.helpers.configureApp(app, { + onPostProcess: onPostConfigureApp.bind(this), + onDownload: onDownloadApp.bind(this), + supportedExtensions: SUPPORTED_EXTENSIONS, + }); this.log.info( `Installing '${srcAppPath}' to the ${this.isRealDevice() ? 'real device' : 'Simulator'} ` + `with UDID '${this.device.udid}'`, @@ -31,8 +40,8 @@ export default { ); } + const bundleId = await this.appInfosCache.extractBundleId(srcAppPath); if (checkVersion) { - const bundleId = await extractBundleId(srcAppPath); const {install} = await this.checkAutInstallationState({ enforceAppInstall: false, fullReset: false, @@ -49,8 +58,10 @@ export default { await this.device.installApp( srcAppPath, - timeoutMs ?? this.opts.appPushTimeout, - strategy ?? this.opts.appInstallStrategy, + bundleId, + { + timeoutMs: timeoutMs ?? this.opts.appPushTimeout + }, ); this.log.info(`Installation of '${srcAppPath}' succeeded`); }, diff --git a/lib/commands/app-strings.js b/lib/commands/app-strings.js index d5c0c25f7..7fdb76b79 100644 --- a/lib/commands/app-strings.js +++ b/lib/commands/app-strings.js @@ -4,8 +4,11 @@ export default { /** * Return the language-specific strings for an app * - * @param {string} language - The language abbreviation to fetch app strings mapping for. If no language is provided then strings for the 'en language would be returned - * @param {string|null} [stringFile=null] - Relative path to the corresponding .strings file starting from the corresponding .lproj folder, e.g., `base/main.strings`. If omitted, then Appium will make its best guess where the file is. + * @param {string} language - The language abbreviation to fetch app strings mapping for. + * If no language is provided then strings for the 'en language would be returned + * @param {string|null} [stringFile=null] - Relative path to the corresponding .strings + * file starting from the corresponding .lproj folder, e.g., `base/main.strings`. If omitted, + * then Appium will make its best guess where the file is. * * @returns {Promise>} A record of localized keys to localized text * @@ -13,7 +16,7 @@ export default { */ async getStrings(language, stringFile = null) { this.log.debug(`Gettings strings for language '${language}' and string file '${stringFile}'`); - return await parseLocalizableStrings( + return await parseLocalizableStrings.bind(this)( Object.assign({}, this.opts, { language, stringFile, diff --git a/lib/commands/file-movement.js b/lib/commands/file-movement.js index c9e64289a..0fb0b1606 100644 --- a/lib/commands/file-movement.js +++ b/lib/commands/file-movement.js @@ -160,7 +160,7 @@ async function pushFileToSimulator(device, remotePath, base64Data) { async function pushFileToRealDevice(device, remotePath, base64Data) { const {service, relativePath} = await createService(device.udid, remotePath); try { - await pushFile(service, relativePath, base64Data); + await pushFile(service, Buffer.from(base64Data, 'base64'), relativePath); } catch (e) { log.debug(e.stack); throw new Error(`Could not push the file to '${remotePath}'. Original error: ${e.message}`); diff --git a/lib/commands/types.ts b/lib/commands/types.ts index cbba4c289..d9f1e861d 100644 --- a/lib/commands/types.ts +++ b/lib/commands/types.ts @@ -219,6 +219,7 @@ export interface View { */ export type SourceFormat = 'xml' | 'json' | 'description'; +/** @deprecated */ export type AppInstallStrategy = 'serial' | 'parallel' | 'ios-deploy'; export interface ProfileManifest { diff --git a/lib/desired-caps.js b/lib/desired-caps.js index c9aa3350c..a5ecdf60c 100644 --- a/lib/desired-caps.js +++ b/lib/desired-caps.js @@ -355,6 +355,7 @@ const desiredCapConstraints = /** @type {const} */ ({ isBoolean: true, }, appInstallStrategy: { + deprecated: true, isString: true, inclusionCaseInsensitive: ['serial', 'parallel', 'ios-deploy'], }, diff --git a/lib/driver.js b/lib/driver.js index e439a3295..f2ce62049 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -14,8 +14,6 @@ import url from 'node:url'; import { SUPPORTED_EXTENSIONS, SAFARI_BUNDLE_ID, - extractBundleId, - extractBundleVersion, onPostConfigureApp, onDownloadApp, verifyApplicationPlatform, @@ -55,7 +53,6 @@ import { getAndCheckXcodeVersion, getDriverInfo, isLocalHost, - isTvOs, markSystemFilesForCleanup, normalizeCommandTimeouts, normalizePlatformVersion, @@ -63,6 +60,7 @@ import { removeAllSessionWebSocketHandlers, translateDeviceName, } from './utils'; +import { AppInfosCache } from './app-infos-cache'; const SHUTDOWN_OTHER_FEAT_NAME = 'shutdown_other_sims'; const CUSTOMIZE_RESULT_BUNDLE_PATH = 'customize_result_bundle_path'; @@ -310,6 +308,7 @@ export class XCUITestDriver extends BaseDriver { } this.lifecycleData = {}; this._audioRecorder = null; + this.appInfosCache = new AppInfosCache(this.log); } async onSettingsUpdate(key, value) { @@ -548,7 +547,7 @@ export class XCUITestDriver extends BaseDriver { await checkAppPresent(this.opts.app); if (!this.opts.bundleId) { - this.opts.bundleId = await extractBundleId(this.opts.app); + this.opts.bundleId = await this.appInfosCache.extractBundleId(this.opts.app); } } @@ -1503,7 +1502,7 @@ export class XCUITestDriver extends BaseDriver { }; } - const candidateBundleVersion = await extractBundleVersion(app); + const candidateBundleVersion = await this.appInfosCache.extractBundleVersion(app); this.log.debug(`CFBundleVersion from Info.plist: ${candidateBundleVersion}`); if (!candidateBundleVersion) { return { @@ -1560,10 +1559,7 @@ export class XCUITestDriver extends BaseDriver { return; } - await verifyApplicationPlatform(this.opts.app, { - isSimulator: this.isSimulator(), - isTvOS: isTvOs(this.opts.platformName), - }); + await verifyApplicationPlatform.bind(this)(); const {install, skipUninstall} = await this.checkAutInstallationState(); if (install) { @@ -1571,7 +1567,6 @@ export class XCUITestDriver extends BaseDriver { await installToRealDevice.bind(this)(this.opts.app, this.opts.bundleId, { skipUninstall, timeout: this.opts.appPushTimeout, - strategy: this.opts.appInstallStrategy, }); } else { await installToSimulator.bind(this)(this.opts.app, this.opts.bundleId, { @@ -1607,9 +1602,13 @@ export class XCUITestDriver extends BaseDriver { } /** @type {string[]} */ - const appPaths = await B.all(appsList.map((app) => this.helpers.configureApp(app, '.app'))); + const appPaths = await B.all(appsList.map((app) => this.helpers.configureApp(app, { + onPostProcess: onPostConfigureApp.bind(this), + onDownload: onDownloadApp.bind(this), + supportedExtensions: SUPPORTED_EXTENSIONS, + }))); /** @type {string[]} */ - const appIds = await B.all(appPaths.map((appPath) => extractBundleId(appPath))); + const appIds = await B.all(appPaths.map((appPath) => this.appInfosCache.extractBundleId(appPath))); for (const [appId, appPath] of _.zip(appIds, appPaths)) { if (this.isRealDevice()) { await installToRealDevice.bind(this)( @@ -1618,7 +1617,6 @@ export class XCUITestDriver extends BaseDriver { { skipUninstall: true, // to make the behavior as same as UIA2 timeout: this.opts.appPushTimeout, - strategy: this.opts.appInstallStrategy, }, ); } else { @@ -1689,7 +1687,7 @@ export class XCUITestDriver extends BaseDriver { return; } - const candidateBundleId = await extractBundleId(this.opts.prebuiltWDAPath); + const candidateBundleId = await this.appInfosCache.extractBundleId(this.opts.prebuiltWDAPath); this.wda.updatedWDABundleId = candidateBundleId.replace('.xctrunner', ''); this.log.info( `Installing prebuilt WDA at '${this.opts.prebuiltWDAPath}'. ` + @@ -1705,7 +1703,6 @@ export class XCUITestDriver extends BaseDriver { { skipUninstall: true, timeout: this.opts.appPushTimeout, - strategy: this.opts.appInstallStrategy, }, ); } else { diff --git a/lib/execute-method-map.ts b/lib/execute-method-map.ts index bff9df7bb..11c28bd7c 100644 --- a/lib/execute-method-map.ts +++ b/lib/execute-method-map.ts @@ -150,7 +150,7 @@ export const executeMethodMap = { command: 'mobileInstallApp', params: { required: ['app'], - optional: ['strategy', 'timeoutMs', 'checkVersion'], + optional: ['timeoutMs', 'checkVersion'], }, }, 'mobile: isAppInstalled': { diff --git a/lib/ios-fs-helpers.js b/lib/ios-fs-helpers.js index 0a12874e6..27c998a7b 100644 --- a/lib/ios-fs-helpers.js +++ b/lib/ios-fs-helpers.js @@ -4,7 +4,7 @@ import {fs, tempDir, mkdirp, zip, util, timing} from 'appium/support'; import path from 'path'; import log from './logger'; -const IO_TIMEOUT_MS = 4 * 60 * 1000; +export const IO_TIMEOUT_MS = 4 * 60 * 1000; // Mobile devices use NAND memory modules for the storage, // and the parallelism there is not as performant as on regular SSDs const MAX_IO_CHUNK_SIZE = 8; @@ -17,7 +17,7 @@ const MAX_IO_CHUNK_SIZE = 8; * @param {string} remotePath Relative path to the file on the device * @returns {Promise} The file content as a buffer */ -async function pullFile(afcService, remotePath) { +export async function pullFile(afcService, remotePath) { const stream = await afcService.createReadStream(remotePath, {autoDestroy: true}); const pullPromise = new B((resolve, reject) => { stream.on('close', resolve); @@ -51,7 +51,7 @@ async function folderExists(folderPath) { * @param {string} remoteRootPath Relative path to the folder on the device * @returns {Promise} The folder content as a zipped base64-encoded buffer */ -async function pullFolder(afcService, remoteRootPath) { +export async function pullFolder(afcService, remoteRootPath) { const tmpFolder = await tempDir.openDir(); try { let localTopItem = null; @@ -145,35 +145,71 @@ async function remoteMkdirp(afcService, remoteRoot) { await afcService.createDirectory(remoteRoot); } +/** + * @typedef {Object} PushFileOptions + * @property {number} [timeoutMs=240000] The maximum count of milliceconds to wait until + * file push is completed. Cannot be lower than 60000ms + */ + /** * Pushes a file to a real device * - * @param {any} afcService Apple File Client service instance from + * @param {any} afcService afcService Apple File Client service instance from * 'appium-ios-device' module + * @param {string|Buffer} localPathOrPayload Either full path to the source file + * or a buffer payload to be written into the remote destination * @param {string} remotePath Relative path to the file on the device. The remote * folder structure is created automatically if necessary. - * @param {string} base64Data Base64-encoded content of the file to be written + * @param {PushFileOptions} [opts={}] */ -async function pushFile(afcService, remotePath, base64Data) { +export async function pushFile (afcService, localPathOrPayload, remotePath, opts = {}) { + const { + timeoutMs = IO_TIMEOUT_MS, + } = opts; + const timer = new timing.Timer().start(); await remoteMkdirp(afcService, path.dirname(remotePath)); - const stream = await afcService.createWriteStream(remotePath, {autoDestroy: true}); + const source = Buffer.isBuffer(localPathOrPayload) + ? localPathOrPayload + : fs.createReadStream(localPathOrPayload, {autoClose: true}); + const writeStream = await afcService.createWriteStream(remotePath, { + autoDestroy: true, + }); + writeStream.on('finish', writeStream.destroy); let pushError = null; - const pushPromise = new B((resolve, reject) => { - stream.on('error', (e) => { - pushError = e; - }); - stream.on('close', () => { + const filePushPromise = new B((resolve, reject) => { + writeStream.on('close', () => { if (pushError) { reject(pushError); } else { resolve(); } }); - }).timeout(IO_TIMEOUT_MS); - stream.write(Buffer.from(base64Data, 'base64')); - stream.end(); - await pushPromise; -} + const onStreamError = (e) => { + if (!Buffer.isBuffer(source)) { + source.unpipe(writeStream); + } + log.debug(e); + pushError = e; + }; + writeStream.on('error', onStreamError); + if (!Buffer.isBuffer(source)) { + source.on('error', onStreamError); + } + }); + if (Buffer.isBuffer(source)) { + writeStream.write(source); + } else { + source.pipe(writeStream); + } + await filePushPromise.timeout(Math.max(timeoutMs, 60000)); + const fileSize = Buffer.isBuffer(localPathOrPayload) + ? localPathOrPayload.length + : (await fs.stat(localPathOrPayload)).size; + log.debug( + `Successfully pushed the file payload (${util.toReadableSizeString(fileSize)}) ` + + `to the remote location '${remotePath}' in ${timer.getDuration().asMilliSeconds.toFixed(0)}ms` + ); +}; /** * @typedef {Object} PushFolderOptions @@ -194,7 +230,7 @@ async function pushFile(afcService, remotePath, base64Data) { * will be deleted if already exists. * @param {PushFolderOptions} opts */ -async function pushFolder(afcService, srcRootPath, dstRootPath, opts = {}) { +export async function pushFolder(afcService, srcRootPath, dstRootPath, opts = {}) { const {timeoutMs = IO_TIMEOUT_MS, enableParallelPush = false} = opts; const timer = new timing.Timer().start(); @@ -239,7 +275,7 @@ async function pushFolder(afcService, srcRootPath, dstRootPath, opts = {}) { `(${util.pluralize('item', foldersToPush.length + 1, true)})`, ); - const pushFile = async (relativePath) => { + const _pushFile = async (/** @type {string} */ relativePath) => { const absoluteSourcePath = path.join(srcRootPath, relativePath); const readStream = fs.createReadStream(absoluteSourcePath, {autoClose: true}); const absoluteDestinationPath = path.join(dstRootPath, relativePath); @@ -272,7 +308,7 @@ async function pushFolder(afcService, srcRootPath, dstRootPath, opts = {}) { log.debug(`Proceeding to parallel files push (max ${MAX_IO_CHUNK_SIZE} writers)`); const pushPromises = []; for (const relativeFilePath of filesToPush) { - pushPromises.push(B.resolve(pushFile(relativeFilePath))); + pushPromises.push(B.resolve(_pushFile(relativeFilePath))); // keep the push queue filled if (pushPromises.length >= MAX_IO_CHUNK_SIZE) { await B.any(pushPromises); @@ -290,7 +326,7 @@ async function pushFolder(afcService, srcRootPath, dstRootPath, opts = {}) { } else { log.debug(`Proceeding to serial files push`); for (const relativeFilePath of filesToPush) { - await pushFile(relativeFilePath); + await _pushFile(relativeFilePath); const elapsedMs = timer.getDuration().asMilliSeconds; if (elapsedMs > timeoutMs) { throw new B.TimeoutError(`Timed out after ${elapsedMs} ms`); @@ -304,5 +340,3 @@ async function pushFolder(afcService, srcRootPath, dstRootPath, opts = {}) { `within ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`, ); } - -export {pullFile, pullFolder, pushFile, pushFolder}; diff --git a/lib/real-device-management.js b/lib/real-device-management.js index f394459c8..3720b0faf 100644 --- a/lib/real-device-management.js +++ b/lib/real-device-management.js @@ -8,7 +8,6 @@ const DEFAULT_APP_INSTALLATION_TIMEOUT_MS = 8 * 60 * 1000; * @typedef {Object} InstallOptions * * @property {boolean} [skipUninstall] Whether to skip app uninstall before installing it - * @property {'serial'|'parallel'|'ios-deploy'} [strategy='serial'] One of possible install strategies ('serial', 'parallel', 'ios-deploy') * @property {number} [timeout=480000] App install timeout * @property {boolean} [shouldEnforceUninstall] Whether to enforce the app uninstallation. e.g. fullReset, or enforceAppInstall is true */ @@ -29,7 +28,6 @@ export async function installToRealDevice(app, bundleId, opts = {}) { const { skipUninstall, - strategy, timeout = DEFAULT_APP_INSTALLATION_TIMEOUT_MS, } = opts; @@ -37,10 +35,12 @@ export async function installToRealDevice(app, bundleId, opts = {}) { this.log.info(`Reset requested. Removing app with id '${bundleId}' from the device`); await device.remove(bundleId); } - this.log.debug(`Installing '${app}' on the device with UUID '${device.udid}'...`); + this.log.debug(`Installing '${app}' on the device with UUID '${device.udid}'`); try { - await device.install(app, timeout, strategy); + await device.install(app, bundleId, { + timeoutMs: timeout, + }); this.log.debug('The app has been installed successfully.'); } catch (e) { // Want to clarify the device's application installation state in this situation. @@ -61,7 +61,9 @@ export async function installToRealDevice(app, bundleId, opts = {}) { `be already cached on the device, probably with a different signature. ` + `Will try to remove it and install a new copy. Original error: ${e.message}`); await device.remove(bundleId); - await device.install(app, timeout, strategy); + await device.install(app, bundleId, { + timeoutMs: timeout, + }); this.log.debug('The app has been installed after one retrial.'); } } diff --git a/lib/real-device.js b/lib/real-device.js index 4127bc280..c87da56ce 100644 --- a/lib/real-device.js +++ b/lib/real-device.js @@ -1,23 +1,16 @@ -import {fs, timing, util} from 'appium/support'; +import {timing, util, fs} from 'appium/support'; import path from 'path'; import {services, utilities, INSTRUMENT_CHANNEL} from 'appium-ios-device'; import B from 'bluebird'; import defaultLogger from './logger'; import _ from 'lodash'; -import {exec} from 'teen_process'; -import {extractBundleId, SAFARI_BUNDLE_ID} from './app-utils'; -import {pushFolder} from './ios-fs-helpers'; +import {SAFARI_BUNDLE_ID} from './app-utils'; +import {pushFile, pushFolder, IO_TIMEOUT_MS} from './ios-fs-helpers'; import { Devicectl } from './devicectl'; const APPLICATION_INSTALLED_NOTIFICATION = 'com.apple.mobile.application_installed'; -const INSTALLATION_STAGING_DIR = 'PublicStaging'; const APPLICATION_NOTIFICATION_TIMEOUT_MS = 30 * 1000; -const IOS_DEPLOY = 'ios-deploy'; -const APP_INSTALL_STRATEGY = Object.freeze({ - SERIAL: 'serial', - PARALLEL: 'parallel', - IOS_DEPLOY, -}); +const INSTALLATION_STAGING_DIR = 'PublicStaging'; /** * @returns {Promise} @@ -26,6 +19,11 @@ export async function getConnectedDevices() { return await utilities.getConnectedDevices(); } +/** + * @typedef {Object} InstallOptions + * @param {number} [timeoutMs=240000] Application installation timeout in milliseconds + */ + /** * @typedef {Object} InstallOrUpgradeOptions * @property {number} timeout Install/upgrade timeout in milliseconds @@ -71,82 +69,52 @@ export class RealDevice { /** * - * @param {string} app - * @param {number} timeout - * @param {'ios-deploy'|'serial'|'parallel'|null} strategy - * @privateRemarks This really needs type guards built out + * @param {string} appPath + * @param {string} bundleId + * @param {InstallOptions} [opts={}] */ - async install(app, timeout, strategy = null) { - if ( - strategy && - !_.values(APP_INSTALL_STRATEGY).includes(/** @type {any} */ (_.toLower(strategy))) - ) { - throw new Error( - `App installation strategy '${strategy}' is unknown. ` + - `Only the following strategies are supported: ${_.values(APP_INSTALL_STRATEGY)}`, - ); - } - this.log.debug( - `Using '${strategy ?? APP_INSTALL_STRATEGY.SERIAL}' app deployment strategy. ` + - `You could change it by providing another value to the 'appInstallStrategy' capability`, - ); - - const installWithIosDeploy = async () => { - try { - await fs.which(IOS_DEPLOY); - } catch (err) { - throw new Error(`'${IOS_DEPLOY}' utility has not been found in PATH. Is it installed?`); - } - try { - await exec(IOS_DEPLOY, ['--id', this.udid, '--bundle', app], {timeout}); - } catch (err) { - throw new Error(err.stderr || err.stdout || err.message); - } - }; - + async install(appPath, bundleId, opts = {}) { + const { + timeoutMs = IO_TIMEOUT_MS, + } = opts; const timer = new timing.Timer().start(); - if (_.toLower(/** @type {'ios-deploy'} */ (strategy)) === APP_INSTALL_STRATEGY.IOS_DEPLOY) { - await installWithIosDeploy(); - } else { - const afcService = await services.startAfcService(this.udid); - const enableParallelPush = _.toLower(/** @type {'parallel'} */ (strategy)) === APP_INSTALL_STRATEGY.PARALLEL; - try { - const bundleId = await extractBundleId(app); - const bundlePathOnPhone = path.join(INSTALLATION_STAGING_DIR, bundleId); - await pushFolder(afcService, app, bundlePathOnPhone, { - enableParallelPush, - timeoutMs: timeout, + const afcService = await services.startAfcService(this.udid); + try { + let bundlePathOnPhone; + if ((await fs.stat(appPath)).isFile()) { + // https://github.com/doronz88/pymobiledevice3/blob/6ff5001f5776e03b610363254e82d7fbcad4ef5f/pymobiledevice3/services/installation_proxy.py#L75 + bundlePathOnPhone = `/${path.basename(appPath)}`; + await pushFile(afcService, appPath, bundlePathOnPhone, { + timeoutMs, }); - await this.installOrUpgradeApplication( - bundlePathOnPhone, - { - timeout: Math.max(timeout - timer.getDuration().asMilliSeconds, 60000), - isUpgrade: await this.isAppInstalled(bundleId), - } - ); - } catch (err) { - this.log.warn(`Error installing app '${app}': ${err.message}`); - if (err instanceof B.TimeoutError) { - this.log.info( - `Consider increasing the value of 'appPushTimeout' capability (the current value equals to ${timeout}ms)` - ); - if (!enableParallelPush) { - this.log.info(`Consider setting the value of 'appInstallStrategy' capability to 'parallel'`); - } - } - this.log.warn(`Falling back to '${IOS_DEPLOY}' usage`); - try { - await installWithIosDeploy(); - } catch (err1) { - throw new Error( - `Could not install '${app}':\n` + ` - ${err.message}\n` + ` - ${err1.message}`, - ); + } else { + bundlePathOnPhone = `${INSTALLATION_STAGING_DIR}/${bundleId}`; + await pushFolder(afcService, appPath, bundlePathOnPhone, { + enableParallelPush: true, + timeoutMs, + }); + } + await this.installOrUpgradeApplication( + bundlePathOnPhone, + { + timeout: Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000), + isUpgrade: await this.isAppInstalled(bundleId), } - } finally { - afcService.close(); + ); + } catch (err) { + this.log.debug(err.stack); + let errMessage = `Cannot install the ${bundleId} application`; + if (err instanceof B.TimeoutError) { + errMessage += `. Consider increasing the value of 'appPushTimeout' capability (the current value equals to ${timeoutMs}ms)`; } + errMessage += `. Original error: ${err.message}`; + throw new Error(errMessage); + } finally { + afcService.close(); } - this.log.info(`App installation succeeded after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); + this.log.info( + `The installation of '${bundleId}' succeeded after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms` + ); } /** @@ -164,10 +132,16 @@ export class RealDevice { const clientOptions = {PackageType: 'Developer'}; try { if (isUpgrade) { - this.log.debug(`An upgrade of the existing application is going to be performed. Will timeout in ${timeout} ms`); + this.log.debug( + `An upgrade of the existing application is going to be performed. ` + + `Will timeout in ${timeout.toFixed(0)} ms` + ); await installationService.upgradeApplication(bundlePathOnPhone, clientOptions, timeout); } else { - this.log.debug(`A new application installation is going to be performed. Will timeout in ${timeout} ms`); + this.log.debug( + `A new application installation is going to be performed. ` + + `Will timeout in ${timeout.toFixed(0)} ms` + ); await installationService.installApplication(bundlePathOnPhone, clientOptions, timeout); } try { @@ -187,12 +161,12 @@ export class RealDevice { /** * Alias for {@linkcode install} - * @param {string} app - * @param {number} timeout - * @param {'ios-deploy'|'serial'|'parallel'|null} strategy + * @param {string} appPath + * @param {string} bundleId + * @param {InstallOptions} [opts={}] */ - async installApp(app, timeout, strategy) { - return await this.install(app, timeout, strategy); + async installApp(appPath, bundleId, opts = {}) { + return await this.install(appPath, bundleId, opts); } /** diff --git a/test/unit/app-infos-cache-specs.js b/test/unit/app-infos-cache-specs.js new file mode 100644 index 000000000..efc306f6f --- /dev/null +++ b/test/unit/app-infos-cache-specs.js @@ -0,0 +1,73 @@ +import { + AppInfosCache, +} from '../../lib/app-infos-cache'; +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { fs, tempDir, zip } from 'appium/support'; +import path from 'node:path'; +import log from '../../lib/logger.js'; + + +chai.should(); +chai.use(chaiAsPromised); + +const BIOMETRIC_BUNDLE_ID = 'com.mwakizaka.biometric'; + +describe('AppInfosCache', function () { + describe('retrives info from different types of apps', function () { + let ipaPath; + const appPath = path.resolve(__dirname, '..', 'assets', 'biometric.app'); + /** @type {AppInfosCache} */ + let cache; + + before(async function () { + const tmpDir = await tempDir.openDir(); + try { + const destDir = path.join(tmpDir, 'Payload', 'biometric.app'); + await fs.mkdirp(destDir); + await fs.copyFile(appPath, destDir); + ipaPath = await tempDir.path({ + prefix: 'foo', + suffix: '.ipa', + }); + await zip.toArchive(ipaPath, { + cwd: tmpDir, + }); + } finally { + await fs.rimraf(tmpDir); + } + }); + + after(async function () { + if (ipaPath && await fs.exists(ipaPath)) { + await fs.rimraf(ipaPath); + ipaPath = undefined; + } + }); + + beforeEach(function () { + cache = new AppInfosCache(log); + }); + + it('should cache ipa', async function () { + const info = await cache.put(ipaPath); + await info.CFBundleIdentifier.should.eql(BIOMETRIC_BUNDLE_ID); + const info2 = await cache.put(ipaPath); + info.should.be.equal(info2); + }); + + it('should cache app', async function () { + const info = await cache.put(appPath); + await info.CFBundleIdentifier.should.eql(BIOMETRIC_BUNDLE_ID); + const info2 = await cache.put(appPath); + info.should.be.equal(info2); + }); + + it('should extract cached info', async function () { + await cache.extractAppPlatforms(appPath).should.eventually.eql(['iPhoneSimulator']); + await cache.extractBundleId(ipaPath).should.eventually.eql(BIOMETRIC_BUNDLE_ID); + await cache.extractBundleVersion(appPath).should.eventually.eql('1'); + await cache.extractExecutableName(ipaPath).should.eventually.eql('biometric'); + }); + }); +}); diff --git a/test/unit/driver-specs.js b/test/unit/driver-specs.js index 17ccc6658..26973c18c 100644 --- a/test/unit/driver-specs.js +++ b/test/unit/driver-specs.js @@ -5,12 +5,12 @@ import chaiAsPromised from 'chai-as-promised'; import _ from 'lodash'; import {createSandbox} from 'sinon'; import sinonChai from 'sinon-chai'; -import * as appUtils from '../../lib/app-utils'; import cmds from '../../lib/commands'; import XCUITestDriver from '../../lib/driver'; import * as utils from '../../lib/utils'; import {MOCHA_LONG_TIMEOUT} from './helpers'; import RealDevice from '../../lib/real-device'; + chai.should(); chai.use(sinonChai).use(chaiAsPromised); @@ -117,6 +117,8 @@ describe('XCUITestDriver', function () { setAutoFillPasswords: _.noop, reset: _.noop, }; + const cacheMock = sandbox.mock(driver.appInfosCache); + cacheMock.expects('extractBundleId').once().returns('bundle.id'); realDevice = null; sandbox .stub(driver, 'determineDevice') @@ -131,7 +133,6 @@ describe('XCUITestDriver', function () { sandbox.stub(driver, 'connectToRemoteDebugger'); sandbox.stub(xcode, 'getMaxIOSSDK').resolves('10.0'); sandbox.stub(utils, 'checkAppPresent'); - sandbox.stub(appUtils, 'extractBundleId'); sandbox.stub(utils, 'getAndCheckXcodeVersion').resolves({ versionString: '20.0', versionFloat: 20.0, @@ -283,7 +284,8 @@ describe('XCUITestDriver', function () { sandbox.stub(RealDeviceManagementModule, 'installToRealDevice'); sandbox.stub(driver, 'isRealDevice').returns(true); sandbox.stub(driver.helpers, 'configureApp').resolves('/path/to/iosApp.app'); - sandbox.stub(appUtils, 'extractBundleId').resolves('bundle-id'); + sandbox.mock(driver.appInfosCache) + .expects('extractBundleId').resolves('bundle-id'); // @ts-expect-error random stuff on opts driver.opts.device = 'some-device'; driver.lifecycleData = {createSim: false}; @@ -293,7 +295,7 @@ describe('XCUITestDriver', function () { expect(RealDeviceManagementModule.installToRealDevice).to.have.been.calledOnceWith( '/path/to/iosApp.app', 'bundle-id', - {skipUninstall: true, timeout: undefined, strategy: undefined}, + {skipUninstall: true, timeout: undefined}, ); }); @@ -304,7 +306,7 @@ describe('XCUITestDriver', function () { const configureAppStub = sandbox.stub(driver.helpers, 'configureApp'); configureAppStub.onCall(0).resolves('/path/to/iosApp1.app'); configureAppStub.onCall(1).resolves('/path/to/iosApp2.app'); - sandbox.stub(appUtils, 'extractBundleId') + sandbox.stub(driver.appInfosCache, 'extractBundleId') .onCall(0).resolves('bundle-id') .onCall(1).resolves('bundle-id2'); // @ts-expect-error random stuff on opts @@ -316,12 +318,12 @@ describe('XCUITestDriver', function () { expect(RealDeviceManagementModule.installToRealDevice).to.have.been.calledWith( '/path/to/iosApp1.app', 'bundle-id', - {skipUninstall: true, timeout: undefined, strategy: undefined}, + {skipUninstall: true, timeout: undefined}, ); expect(RealDeviceManagementModule.installToRealDevice).to.have.been.calledWith( '/path/to/iosApp2.app', 'bundle-id2', - {skipUninstall: true, timeout: undefined, strategy: undefined}, + {skipUninstall: true, timeout: undefined}, ); }); @@ -330,7 +332,8 @@ describe('XCUITestDriver', function () { sandbox.stub(SimulatorManagementModule, 'installToSimulator'); sandbox.stub(driver, 'isRealDevice').returns(false); sandbox.stub(driver.helpers, 'configureApp').resolves('/path/to/iosApp.app'); - sandbox.stub(appUtils, 'extractBundleId').resolves('bundle-id'); + sandbox.mock(driver.appInfosCache) + .expects('extractBundleId').resolves('bundle-id'); driver.opts.noReset = false; // @ts-expect-error random stuff on opts driver.opts.device = 'some-device'; @@ -352,7 +355,7 @@ describe('XCUITestDriver', function () { const configureAppStub = sandbox.stub(driver.helpers, 'configureApp'); configureAppStub.onCall(0).resolves('/path/to/iosApp1.app'); configureAppStub.onCall(1).resolves('/path/to/iosApp2.app'); - sandbox.stub(appUtils, 'extractBundleId') + sandbox.stub(driver.appInfosCache, 'extractBundleId') .onCall(0).resolves('bundle-id') .onCall(1).resolves('bundle-id2'); driver.opts.noReset = false; From f9f3b3fbb5c749f104c4eb83f7f25afde6900f32 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 26 Apr 2024 18:16:23 +0000 Subject: [PATCH 14/50] chore(release): 7.15.0 [skip ci] ## [7.15.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.14.0...v7.15.0) (2024-04-26) ### Features * Avoid unzipping of real device .ipa bundles ([#2388](https://github.com/appium/appium-xcuitest-driver/issues/2388)) ([520168a](https://github.com/appium/appium-xcuitest-driver/commit/520168aa7d8c230a44da136b9e8d21971c4ef8f8)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b66e559a2..d8bf6e4c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.15.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.14.0...v7.15.0) (2024-04-26) + + +### Features + +* Avoid unzipping of real device .ipa bundles ([#2388](https://github.com/appium/appium-xcuitest-driver/issues/2388)) ([520168a](https://github.com/appium/appium-xcuitest-driver/commit/520168aa7d8c230a44da136b9e8d21971c4ef8f8)) + ## [7.14.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.13.0...v7.14.0) (2024-04-23) diff --git a/package.json b/package.json index 0932f4bc0..832e760cf 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.14.0", + "version": "7.15.0", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 04241937414ee6fa986be8719fbb690046b63a56 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sat, 27 Apr 2024 11:36:29 +0200 Subject: [PATCH 15/50] fix: Update caching logic for extracted app bundles (#2389) --- lib/app-utils.js | 47 +++++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/lib/app-utils.js b/lib/app-utils.js index 6eeeda671..ea9a6b25e 100644 --- a/lib/app-utils.js +++ b/lib/app-utils.js @@ -613,23 +613,38 @@ export async function onPostConfigureApp({cachedAppInfo, isUrl, appPath}) { /** @type {import('@appium/types').CachedAppInfo|undefined} */ const appInfo = _.isPlainObject(cachedAppInfo) ? cachedAppInfo : undefined; const cachedPath = appInfo ? /** @type {string} */ (appInfo.fullPath) : undefined; - if ( - // If cache is present - appInfo && cachedPath - // And if the path exists - && await fs.exists(cachedPath) - // And if hash matches to the cached one if this is a file - // Or count of files >= of the cached one if this is a folder - && ( - ((await fs.stat(cachedPath)).isFile() - && await fs.hash(cachedPath) === /** @type {any} */ (appInfo.integrity)?.file) - || (await fs.glob('**/*', {cwd: cachedPath})).length >= /** @type {any} */ ( - appInfo.integrity - )?.folder - ) - ) { + + const shouldUseCachedApp = async () => { + if (!appInfo || !cachedPath || !await fs.exists(cachedPath)) { + return false; + } + + const isCachedPathAFile = (await fs.stat(cachedPath)).isFile(); + if (isCachedPathAFile) { + return await fs.hash(cachedPath) === /** @type {any} */ (appInfo.integrity)?.file; + } + // If the cached path is a folder then it is expected to be previously extracted from + // an archive located under appPath whose hash is stored as `cachedAppInfo.packageHash` + if ( + !isCachedPathAFile + && cachedAppInfo?.packageHash + && await fs.exists(/** @type {string} */ (appPath)) + && (await fs.stat(/** @type {string} */ (appPath))).isFile() + && cachedAppInfo.packageHash === await fs.hash(/** @type {string} */ (appPath)) + ) { + /** @type {number|undefined} */ + const nestedItemsCountInCache = /** @type {any} */ (appInfo.integrity)?.folder; + if (nestedItemsCountInCache !== undefined) { + return (await fs.glob('**/*', {cwd: cachedPath})).length >= nestedItemsCountInCache; + } + } + + return false; + }; + + if (await shouldUseCachedApp()) { this.log.info(`Using '${cachedPath}' which was cached from '${appPath}'`); - return {appPath: cachedPath}; + return {appPath: /** @type {string} */ (cachedPath)}; } const isLocalIpa = await isIpaBundle(/** @type {string} */(appPath)); From 32227b4e19562cfc913d77caf69137f903b78b6f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 27 Apr 2024 09:38:12 +0000 Subject: [PATCH 16/50] chore(release): 7.15.1 [skip ci] ## [7.15.1](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.0...v7.15.1) (2024-04-27) ### Bug Fixes * Update caching logic for extracted app bundles ([#2389](https://github.com/appium/appium-xcuitest-driver/issues/2389)) ([0424193](https://github.com/appium/appium-xcuitest-driver/commit/04241937414ee6fa986be8719fbb690046b63a56)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8bf6e4c1..8e97dfebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.15.1](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.0...v7.15.1) (2024-04-27) + + +### Bug Fixes + +* Update caching logic for extracted app bundles ([#2389](https://github.com/appium/appium-xcuitest-driver/issues/2389)) ([0424193](https://github.com/appium/appium-xcuitest-driver/commit/04241937414ee6fa986be8719fbb690046b63a56)) + ## [7.15.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.14.0...v7.15.0) (2024-04-26) diff --git a/package.json b/package.json index 832e760cf..5c9558e64 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.15.0", + "version": "7.15.1", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 476bf41947221d5f1b2e59436290b226d8757056 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 2 May 2024 09:24:02 +0200 Subject: [PATCH 17/50] docs: Add WDA slowness troubleshooting document (#2391) --- docs/guides/wda-slowness.md | 231 +++++++++++++++++++++++++++ docs/reference/locator-strategies.md | 14 +- mkdocs.yml | 1 + 3 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 docs/guides/wda-slowness.md diff --git a/docs/guides/wda-slowness.md b/docs/guides/wda-slowness.md new file mode 100644 index 000000000..1633c3030 --- /dev/null +++ b/docs/guides/wda-slowness.md @@ -0,0 +1,231 @@ +--- +title: Diagnosing WebDriverAgent Slowness +--- + +The XCUITest driver is based on Apple's [XCTest](https://developer.apple.com/documentation/xctest) +test automation framework and thus inherits most (if not all) properties and features this framework +provides. The purpose of this article is to help with optimization of automation scenarios that +don't perform well and/or to explain possible causes of such behavior. + +## "Slowness" could be different + +First, it is important to figure out what exactly is slow. +The Appium ecosystem is complicated and +consists of multiple layers, where each layer could influence the overall duration. +For example, when an API call is invoked from a client script, it must go through the following stages: + +Your automation script (Java, Python, C#, etc; runs on your machine) +--> Appium Client Lib (Java, Python, C#, etc; runs on your machine) +--> Appium Server (Node.js HTTP server; runs on your machine or a remote one) +--> XCUITest Driver and/or Plugin (Node.js HTTP handler; runs on your machine or a remote one) +--> WDA Server (ObjectiveC HTTP Server; runs on the remote mobile device) + +The example above is the simplest flow. If you run your scripts using cloud providers +infrastructure then the amount of intermediate components in this chain may be much greater. +Like it was mentioned above, it is very important to know on which stage(s) +(or between them) the bottleneck is observed. + +This particular article focuses only on the last stage: the WDA Server one. + +## WebDriverAgent (WDA) Server + +WDA source code is located in the separate [repository](https://github.com/appium/WebDriverAgent/tree/master). +The content of this repository is published as [appium-webdriveragent](https://www.npmjs.com/package/appium-webdriveragent) +NPM package and contains several helper Node.js modules along with the WDA source code itself. +This source code is compiled into an .xctrunner bundle, which is a special application type +that contains tests (also it has some higher privileges in comparison to vanilla apps). +WebDriverAgent project itself consists of three main parts: + +- Vendor Libs +- WebDriverAgentLib +- WebDriverAgentRunner + +Vendor libs, like RoutingHTTPServer, ensure the support for low-level HTTP- and TCP-server APIs. +WebDriverAgentLib defines handlers for [W3C WebDriver](https://www.w3.org/TR/webdriver/) endpoints +and implements all the heavy-lifting procedures related to Apple's XCTest communication +and some more custom stuff specific for the XCUITest driver. +WebDriverAgentRunner is actually one long test, whose main purpose +is to run the HTTP server implemented by the WebDriverAgentLib. + +Important conclusions from the above information: + +- WDA is an HTTP server, which executes API commands by invoking HTTP response handlers +- WDA uses Apple's XCTest APIs with various custom additions + +## How to confirm my script's bottleneck is WDA + +Check the server logs in order to verify how long it takes for the XCUITest driver to receive a +response from WDA. The log line that is written before an HTTP request is proxied to WDA looks +like `Proxying [X] to [Y]`. Also consider enabling server timestamps by providing the +`--log-timestamp` command line parameter. If you observe timestamps between the above log line and the +next one differ too much and the difference is an anomaly (e.g. the same step is (much) faster +for other apps/environments/parameter combinations) then it might serve as a confirmation of a +suspicious slowness. + +## Patterns lookup + +After the slowness is confirmed it is important to determine behavior patterns, e.g. under which +circumstances does it happen, if it is always reproducible, etc. This article only targets specific +patterns that the author knows of or dealt with. If your pattern is not present here then try to +look for possible occurrences in existing [issues](https://github.com/appium/appium/issues), +[Appium forum](https://discuss.appium.io) or just search the internet. + +## Pattern: Application startup is slow + +### Symptoms + +You observe timeouts or unusual slowness (in comparison to manual execution performance) +of the application startup on session init (if it also includes app startup) +or mid-session app startup. + +### Causes + +When XCTest starts an app it ensures the accessibility layer of it is ready for interactions. +To check that the framework verifies the application is idling (e.g. does not perform any actions +on the main thread) as well as all animations have been finished. If this check times out +an exception is thrown or WDA may try to continue without any guarantees the app could be +interacted with (a.k.a. best effort strategy). + +### Solutions + +I was observing applications that were constantly running something on the main thread in an endless loop. +Most likely such apps are not automatable at all or hardly automatable without fixing the app +source code itself. +You may still try to tune the following capabilities and settings to influence the above timeout: + +- [appium:waitForIdleTimeout](../reference/capabilities.md) +- [waitForIdleTimeout](../reference/settings.md) +- [animationCoolOffTimeout](../reference/settings.md) + +## Pattern: Element location with XPath is slow + +### Symptoms + +You observe timeouts or unusual slowness (in comparison to other location strategies) +of XPath locators. + +### Causes + +The [XPath](../reference/locator-strategies.md) location strategy +is not natively supported by XCTest. It's a custom addition +which is only available in WDA. Such locators have more features than others, but the price +for it is the observed slowness as we cannot rely on native XCTest location APIs +while looking for elements using XPath. +In order to perform XPath lookup WDA needs to take a snapshot of the whole accessibility +hierarchy with all element attributes resolved, which is a time-expensive operation. +Location slowness might be observed if: +- The current app hierarchy is too large (e.g. has hundreds of elements). This is a known + XCTest limitation. +- The app is not idling/has active animations +- It takes too long to determine each element's `visible` or `accessible` attributes, which are custom + ones and are not present in the original XCTest implementation + +### Solutions + +Depending on the actual cause there might be different applicable solutions. In general, the common +advice would be to avoid XPath locators where possible and use locators that are natively +supported by XCTest (like predicates or ids) and have better speed ranking. +If the usage of an XPath locators is a single available option then you may try to apply the following +suggestions: +- Reduce the size of the app hierarchy using the [snapshotMaxDepth setting](../reference/settings.md). + This might not help if the destination element is deeply nested - + it won't be found if the value of this setting is lower than its nesting level. +- Exclude the `visible` and/or `accessible` attributes from your query. These are + custom attributes exclusive to WDA and their calculation is expensive in comparison + to other native attributes. +- Reduce various timeouts similarly to how it's advised in the + [Application startup is slow](#pattern-application-startup-is-slow) pattern +- Fix the source code of the application under test to reduce the amount of accessible elements + on a single screen +- Fix the source code of the application under test to avoid running long operations + or animations on the main thread + +## Pattern: Element location with non-XPath is slow + +### Symptoms + +You observe timeouts or unusual slowness with various non-XPath locators. + +### Causes + +Location slowness might be observed if: +- The current app hierarchy is too large (e.g. has hundreds of elements). This is a known + XCTest limitation. +- The app is not idling/has active animations +- It takes too long to determine each element's `visible` or `accessible` attributes, which are custom + ones and are not present in the original XCTest implementation (only applicable to predicate and class chain locators) + +### Solutions + +- Reduce the size of the app hierarchy using the [snapshotMaxDepth setting](../reference/settings.md). + This might not help if the destination element is deeply nested - + it won't be found if the value of this setting is lower than its nesting level. +- Exclude the `visible` and/or `accessible` attributes from your query + (only applicable to predicate and class chain locators). These are + custom attributes exclusive to WDA and their calculation is expensive in comparison + to other native attributes. +- Reduce various timeouts similarly to how it's advised in the + [Application startup is slow](#pattern-application-startup-is-slow) pattern +- Fix the source code of the application under test to reduce the amount of accessible elements + on a single screen +- Fix the source code of the application under test to avoid running long operations + or animations on the main thread + +## Pattern: Various element interactions are slow + +### Symptoms + +You observe timeouts or unusual slowness while clicking elements or performing other +actions on them. + +### Causes + +- The current app hierarchy is too large (e.g. has hundreds of elements). This is a known + XCTest limitation. +- The app is not idling/has active animations + +### Solutions + +- Reduce various timeouts similarly to how it's advised in the + [Application startup is slow](#pattern-application-startup-is-slow) pattern +- Fix the source code of the application under test to reduce the amount of accessible elements + on a single screen +- Fix the source code of the application under test to avoid running long operations + or animations on the main thread + +## Pattern: Page source retrieval slow + +### Symptoms + +You observe timeouts or unusual slowness while retrieving the page of the app. + +### Causes + +In order to retrieve the page source WDA needs to take a snapshot of the whole accessibility +hierarchy with all element attributes resolved, which is a time-expensive operation. +Page source retrieval slowness might be observed if: +- The current app hierarchy is too large (e.g. has hundreds of elements). This is a known + XCTest limitation. +- The app is not idling/has active animations +- It takes too long to determine each element's `visible` or `accessible` attributes, which are custom + ones and are not present in the original XCTest implementation + +### Solutions + +- Reduce the size of the app hierarchy using the [snapshotMaxDepth setting](../reference/settings.md). + Note that you won't see nested elements in the source tree whose nesting level is greater than + the given size. +- Retrieve the page source without "expensive" attributes using the + [mobile: source](../reference/execute-methods.md#mobile-source) method with + the appropriate `excludedAttributes` argument value or add such attribute names into + the [pageSourceExcludedAttributes setting](../reference/settings.md). +- Retrieve the native XCTest page source using the + [mobile: source](../reference/execute-methods.md#mobile-source) method with + the `format=description` argument value. The returned page source is a poorly-formatted text, + although its retrieval should be fast (at least not slower than XCTest does that). +- Reduce various timeouts similarly to how it's advised in the + [Application startup is slow](#pattern-application-startup-is-slow) pattern +- Fix the source code of the application under test to reduce the amount of accessible elements + on a single screen +- Fix the source code of the application under test to avoid running long operations + or animations on the main thread diff --git a/docs/reference/locator-strategies.md b/docs/reference/locator-strategies.md index c8984f278..27c6eab2d 100644 --- a/docs/reference/locator-strategies.md +++ b/docs/reference/locator-strategies.md @@ -8,12 +8,12 @@ title: Locator Strategies The XCUITest driver supports several location strategies in the native context. The following table lists them in performance order (the first one is the fastest one): -|
Name
| Description | Example | -| --- | --- | --- | -| `className` | Performs search by element's `type` [attribute](element-attributes.md). The full list of supported XCUIElement type names could be found in the official XCTest [documentation on XCUIElementType](https://developer.apple.com/documentation/xctest/xcuielementtype) | `XCUIElementTypeButton` | -| `id`, `name`, `accessibility id` | All these locator types are synonyms and internally get transformed into search by element's `name` [attribute](element-attributes.md). | `my name` | -| `-ios predicate string` | This strategy is mapped to the native XCTest predicate locator. Check the [NSPredicate cheat sheet](https://academy.realm.io/posts/nspredicate-cheatsheet/) for more details on how to build effective predicate expressions. All the supported element [attributes](element-attributes.md) could be used in these expressions. | `(name == 'done' OR value == 'done') AND type IN {'XCUIElementTypeButton', 'XCUIElementTypeKey'}` | -| `-ios class chain` | This strategy is mapped to the native XCTest predicate locator, but with respect to the actual element tree hierarchy. Such locators are basically a supertype of `-ios predicate string`. Read [Class Chain Queries Construction Rules](https://github.com/facebookarchive/WebDriverAgent/wiki/Class-Chain-Queries-Construction-Rules) for more details on how to build such locators. | ```**/XCUIElementTypeCell[$name == 'done' OR value == 'done'$]/XCUIElementTypeButton[-1]``` | -| `xpath` | For elements lookup using the Xpath strategy the driver uses the same XML tree that is generated by the page source API. This means such locators are the slowest (sometimes up to 10x slower) in comparison to the ones above, which all depend on native XCTest primitives, but are the most flexible. Use Xpath locators only if there is no other way to locate the given element. Only Xpath 1.0 is supported. | `//XCUIElementTypeButton[@value=\"Regular\"]/parent::*` | +|
Name
| Description | Speed Ranking | Example | +| --- | --- | --- | --- | +| `className` | Performs search by element's `type` [attribute](element-attributes.md). The full list of supported XCUIElement type names could be found in the official XCTest [documentation on XCUIElementType](https://developer.apple.com/documentation/xctest/xcuielementtype) | ⭐⭐⭐⭐⭐ | `XCUIElementTypeButton` | +| `id`, `name`, `accessibility id` | All these locator types are synonyms and internally get transformed into search by element's `name` [attribute](element-attributes.md). | ⭐⭐⭐⭐⭐ | `my name` | +| `-ios predicate string` | This strategy is mapped to the native XCTest predicate locator. Check the [NSPredicate cheat sheet](https://academy.realm.io/posts/nspredicate-cheatsheet/) for more details on how to build effective predicate expressions. All the supported element [attributes](element-attributes.md) could be used in these expressions. | ⭐⭐⭐⭐⭐ | `(name == 'done' OR value == 'done') AND type IN {'XCUIElementTypeButton', 'XCUIElementTypeKey'}` | +| `-ios class chain` | This strategy is mapped to the native XCTest predicate locator, but with respect to the actual element tree hierarchy. Such locators are basically a supertype of `-ios predicate string`. Read [Class Chain Queries Construction Rules](https://github.com/facebookarchive/WebDriverAgent/wiki/Class-Chain-Queries-Construction-Rules) for more details on how to build such locators. | ⭐⭐⭐⭐ | ```**/XCUIElementTypeCell[$name == 'done' OR value == 'done'$]/XCUIElementTypeButton[-1]``` | +| `xpath` | For elements lookup using the Xpath strategy the driver uses the same XML tree that is generated by the page source API. This means such locators are the slowest (sometimes up to 10x slower) in comparison to the ones above, which all depend on native XCTest primitives, but are the most flexible. Use Xpath locators only if there is no other way to locate the given element. Only Xpath 1.0 is supported. | ⭐⭐ | `//XCUIElementTypeButton[@value=\"Regular\"]/parent::*` | Also, consider checking the [How To Achieve The Best Lookup Performance](https://github.com/facebookarchive/WebDriverAgent/wiki/How-To-Achieve-The-Best-Lookup-Performance) article. diff --git a/mkdocs.yml b/mkdocs.yml index e2364d0b6..ba7c611e6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,7 @@ nav: - guides/run-prebuilt-wda.md - guides/attach-to-running-wda.md - guides/wda-custom-server.md + - guides/wda-slowness.md - Driver Actions: - guides/audio-capture.md - guides/file-transfer.md From edea22254521b9b34b885b6e676aa317d8289176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= <37242620+eglitise@users.noreply.github.com> Date: Thu, 2 May 2024 21:42:23 +0300 Subject: [PATCH 18/50] ci: adjust unit test & docs publishing workflows (#2392) --- .github/workflows/publish-doc.yml | 9 ++------- .github/workflows/unit-test.yml | 12 +++++++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish-doc.yml b/.github/workflows/publish-doc.yml index 01d00cddd..297764d55 100644 --- a/.github/workflows/publish-doc.yml +++ b/.github/workflows/publish-doc.yml @@ -15,11 +15,6 @@ jobs: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [18.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - steps: - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3 with: @@ -27,10 +22,10 @@ jobs: - run: | git config user.name github-actions git config user.email github-actions@github.com - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js LTS uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3 with: - node-version: ${{ matrix.node-version }} + node-version: lts/* - run: npm install - name: Install dependencies (Python) run: npm run install-docs-deps diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 3c6bd6103..a1a5c4453 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -2,19 +2,25 @@ name: Unit Tests on: pull_request: branches: [ master ] + paths-ignore: + - 'docs/**' + - '*.md' push: branches: [ master ] + paths-ignore: + - 'docs/**' + - '*.md' jobs: # https://thekevinwang.com/2021/09/19/github-actions-dynamic-matrix/ prepare_matrix: runs-on: ubuntu-latest outputs: - versions: ${{ steps.generate-matrix.outputs.versions }} + versions: ${{ steps.generate-matrix.outputs.active }} steps: - - name: Select 3 most recent LTS versions of Node.js + - name: Select all active LTS versions of Node.js id: generate-matrix - run: echo "versions=$(curl -s https://endoflife.date/api/nodejs.json | jq -c '[[.[] | select(.lts != false)][:3] | .[].cycle | tonumber]')" >> "$GITHUB_OUTPUT" + uses: msimerson/node-lts-versions@v1 test: runs-on: ubuntu-latest From ffd3bbbe8ef3e9ef80c2b9af327d88be6e0f367a Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Tue, 7 May 2024 09:59:30 +0200 Subject: [PATCH 19/50] fix: Properly cache manifests for .ipa bundles containing multiple apps (#2394) --- lib/app-infos-cache.js | 166 +++++++++++++++++++++++------------------ 1 file changed, 94 insertions(+), 72 deletions(-) diff --git a/lib/app-infos-cache.js b/lib/app-infos-cache.js index 414a8be1c..86cc9d732 100644 --- a/lib/app-infos-cache.js +++ b/lib/app-infos-cache.js @@ -10,6 +10,9 @@ const MANIFEST_CACHE = new LRUCache({ updateAgeOnHas: true, }); const MANIFEST_FILE_NAME = 'Info.plist'; +const IPA_ROOT_PLIST_PATH_PATTERN = new RegExp( + `^Payload/[^./]+\\.app/${_.escapeRegExp(MANIFEST_FILE_NAME)}$` +); const MAX_MANIFEST_SIZE = 1024 * 1024; // 1 MiB export class AppInfosCache { @@ -22,130 +25,135 @@ export class AppInfosCache { /** * - * @param {string} appPath + * @param {string} bundlePath Full path to the .ipa or .app bundle * @param {string} propertyName * @returns {Promise} */ - async extractManifestProperty (appPath, propertyName) { - const result = (await this.put(appPath))[propertyName]; + async extractManifestProperty (bundlePath, propertyName) { + const result = (await this.put(bundlePath))[propertyName]; this.log.debug(`${propertyName}: ${JSON.stringify(result)}`); return result; } /** * - * @param {string} appPath + * @param {string} bundlePath Full path to the .ipa or .app bundle * @returns {Promise} */ - async extractBundleId (appPath) { - return await this.extractManifestProperty(appPath, 'CFBundleIdentifier'); + async extractBundleId (bundlePath) { + return await this.extractManifestProperty(bundlePath, 'CFBundleIdentifier'); } /** * - * @param {string} appPath + * @param {string} bundlePath Full path to the .ipa or .app bundle * @returns {Promise} */ - async extractBundleVersion (appPath) { - return await this.extractManifestProperty(appPath, 'CFBundleVersion'); + async extractBundleVersion (bundlePath) { + return await this.extractManifestProperty(bundlePath, 'CFBundleVersion'); } /** * - * @param {string} appPath + * @param {string} bundlePath Full path to the .ipa or .app bundle * @returns {Promise} */ - async extractAppPlatforms (appPath) { - const result = await this.extractManifestProperty(appPath, 'CFBundleSupportedPlatforms'); + async extractAppPlatforms (bundlePath) { + const result = await this.extractManifestProperty(bundlePath, 'CFBundleSupportedPlatforms'); if (!Array.isArray(result)) { - throw new Error(`${path.basename(appPath)}': CFBundleSupportedPlatforms is not a valid list`); + throw new Error(`${path.basename(bundlePath)}': CFBundleSupportedPlatforms is not a valid list`); } return result; } /** * - * @param {string} appPath + * @param {string} bundlePath Full path to the .ipa or .app bundle * @returns {Promise} */ - async extractExecutableName (appPath) { - return await this.extractManifestProperty(appPath, 'CFBundleExecutable'); + async extractExecutableName (bundlePath) { + return await this.extractManifestProperty(bundlePath, 'CFBundleExecutable'); } /** * - * @param {string} appPath Full path to the .ipa or .app bundle + * @param {string} bundlePath Full path to the .ipa or .app bundle * @returns {Promise} The payload of the manifest plist * @throws {Error} If the given app is not a valid bundle */ - async put (appPath) { - const readPlist = async (/** @type {string} */ plistPath) => { - try { - return await plist.parsePlistFile(plistPath); - } catch (e) { - this.log.debug(e.stack); - throw new Error(`Cannot parse ${MANIFEST_FILE_NAME} of '${appPath}'. Is it a valid application bundle?`); - } - }; + async put (bundlePath) { + return (await fs.stat(bundlePath)).isFile() + ? await this._putIpa(bundlePath) + : await this._putApp(bundlePath); + } - if ((await fs.stat(appPath)).isFile()) { - /** @type {import('@appium/types').StringRecord|undefined} */ - let manifestPayload; - /** @type {Error|undefined} */ - let lastError; - try { - await zip.readEntries(appPath, async ({entry, extractEntryTo}) => { - if (!_.endsWith(entry.fileName, `.app/${MANIFEST_FILE_NAME}`)) { - return true; - } + /** + * @param {string} ipaPath Fill path to the .ipa bundle + * @returns {Promise} The payload of the manifest plist + */ + async _putIpa(ipaPath) { + /** @type {import('@appium/types').StringRecord|undefined} */ + let manifestPayload; + /** @type {Error|undefined} */ + let lastError; + try { + await zip.readEntries(ipaPath, async ({entry, extractEntryTo}) => { + // https://github.com/appium/appium/issues/20075 + if (!IPA_ROOT_PLIST_PATH_PATTERN.test(entry.fileName)) { + return true; + } - const hash = `${entry.crc32}`; - if (MANIFEST_CACHE.has(hash)) { - manifestPayload = MANIFEST_CACHE.get(hash); - return false; - } - const tmpRoot = await tempDir.openDir(); - try { - await extractEntryTo(tmpRoot); - const plistPath = path.resolve(tmpRoot, entry.fileName); - manifestPayload = await readPlist(plistPath); - if (entry.uncompressedSize <= MAX_MANIFEST_SIZE && _.isPlainObject(manifestPayload)) { - this.log.debug( - `Caching the manifest for ${manifestPayload?.CFBundleIdentifier} app ` + - `from an archived source using the key '${hash}'` - ); - MANIFEST_CACHE.set(hash, manifestPayload); - } - } catch (e) { - this.log.debug(e.stack); - lastError = e; - } finally { - await fs.rimraf(tmpRoot); - } + const hash = `${entry.crc32}`; + if (MANIFEST_CACHE.has(hash)) { + manifestPayload = MANIFEST_CACHE.get(hash); return false; - }); - } catch (e) { - this.log.debug(e.stack); - throw new Error(`Cannot find ${MANIFEST_FILE_NAME} in '${appPath}'. Is it a valid application bundle?`); - } - if (!manifestPayload) { - let errorMessage = `Cannot extract ${MANIFEST_FILE_NAME} from '${appPath}'. Is it a valid application bundle?`; - if (lastError) { - errorMessage += ` Original error: ${lastError.message}`; } - throw new Error(errorMessage); + const tmpRoot = await tempDir.openDir(); + try { + await extractEntryTo(tmpRoot); + const plistPath = path.resolve(tmpRoot, entry.fileName); + manifestPayload = await this._readPlist(plistPath, ipaPath); + if (_.isPlainObject(manifestPayload) && entry.uncompressedSize <= MAX_MANIFEST_SIZE) { + this.log.debug( + `Caching the manifest '${entry.fileName}' for ${manifestPayload?.CFBundleIdentifier} app ` + + `from the compressed source using the key '${hash}'` + ); + MANIFEST_CACHE.set(hash, manifestPayload); + } + } catch (e) { + this.log.debug(e.stack); + lastError = e; + } finally { + await fs.rimraf(tmpRoot); + } + return false; + }); + } catch (e) { + this.log.debug(e.stack); + throw new Error(`Cannot find ${MANIFEST_FILE_NAME} in '${ipaPath}'. Is it a valid application bundle?`); + } + if (!manifestPayload) { + let errorMessage = `Cannot extract ${MANIFEST_FILE_NAME} from '${ipaPath}'. Is it a valid application bundle?`; + if (lastError) { + errorMessage += ` Original error: ${lastError.message}`; } - return manifestPayload; + throw new Error(errorMessage); } + return manifestPayload; + } - // appPath points to a folder + /** + * @param {string} appPath Fill path to the .app bundle + * @returns {Promise} The payload of the manifest plist + */ + async _putApp(appPath) { const manifestPath = path.join(appPath, MANIFEST_FILE_NAME); const hash = await fs.hash(manifestPath); if (MANIFEST_CACHE.has(hash)) { return /** @type {import('@appium/types').StringRecord} */ (MANIFEST_CACHE.get(hash)); } const [payload, stat] = await B.all([ - readPlist(manifestPath), + this._readPlist(manifestPath, appPath), fs.stat(manifestPath), ]); if (stat.size <= MAX_MANIFEST_SIZE && _.isPlainObject(payload)) { @@ -156,4 +164,18 @@ export class AppInfosCache { } return payload; } + + /** + * @param {string} plistPath Full path to the plist + * @param {string} bundlePath Full path to .ipa or .app bundle + * @returns {Promise} The payload of the plist file + */ + async _readPlist(plistPath, bundlePath) { + try { + return await plist.parsePlistFile(plistPath); + } catch (e) { + this.log.debug(e.stack); + throw new Error(`Cannot parse ${MANIFEST_FILE_NAME} of '${bundlePath}'. Is it a valid application bundle?`); + } + } } From 11157177161ba0900a522520dec576e1afa7bffb Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 7 May 2024 08:01:19 +0000 Subject: [PATCH 20/50] chore(release): 7.15.2 [skip ci] ## [7.15.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.1...v7.15.2) (2024-05-07) ### Bug Fixes * Properly cache manifests for .ipa bundles containing multiple apps ([#2394](https://github.com/appium/appium-xcuitest-driver/issues/2394)) ([ffd3bbb](https://github.com/appium/appium-xcuitest-driver/commit/ffd3bbbe8ef3e9ef80c2b9af327d88be6e0f367a)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e97dfebf..acd29d087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.15.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.1...v7.15.2) (2024-05-07) + + +### Bug Fixes + +* Properly cache manifests for .ipa bundles containing multiple apps ([#2394](https://github.com/appium/appium-xcuitest-driver/issues/2394)) ([ffd3bbb](https://github.com/appium/appium-xcuitest-driver/commit/ffd3bbbe8ef3e9ef80c2b9af327d88be6e0f367a)) + ## [7.15.1](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.0...v7.15.1) (2024-04-27) diff --git a/package.json b/package.json index 5c9558e64..2bb74a500 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.15.1", + "version": "7.15.2", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 154ccee6172a8f516211266c5faaa4a983aec6d9 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 9 May 2024 18:31:01 +0200 Subject: [PATCH 21/50] ci: Bump conventional-pr-action to v3 (#2395) --- .github/workflows/pr-title.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 420d03498..393ca3bd6 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -8,7 +8,7 @@ jobs: name: https://www.conventionalcommits.org runs-on: ubuntu-latest steps: - - uses: beemojs/conventional-pr-action@v2 + - uses: beemojs/conventional-pr-action@v3 with: config-preset: angular env: From 39e52aeed5a73e43acd6cf0534f5bff42291dac1 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sat, 11 May 2024 09:47:59 +0200 Subject: [PATCH 22/50] docs: Update the documentation for the `mobile: activeAppInfo` API (#2397) --- docs/reference/execute-methods.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/reference/execute-methods.md b/docs/reference/execute-methods.md index bfadee830..1eae5f7be 100644 --- a/docs/reference/execute-methods.md +++ b/docs/reference/execute-methods.md @@ -465,7 +465,14 @@ Returns information about the active application. #### Returned Result -Check the `+ (id)handleActiveAppInfo:(FBRouteRequest *)request` method in [FBCustomCommands.m](https://github.com/appium/WebDriverAgent/blob/master/WebDriverAgentLib/Commands/FBCustomCommands.m) for more details on the available map entries. +The API returns a map with the following entries + +Name | Type | Description | Example +--- | --- | --- | --- +pid | number | The process identifier of the active application | 1234 +bundleId | string | The bundle identifier of the active application | com.yolo.myapp +name | string | The name of the active application, if present | Safari +processArguments | map | The map containing actual process arguments. Check the description of the [appium:processArguments capability](./capabilities.md#webdriveragent) for more details on its format. Might be empty if no process arguments have been provided on the app startup. | {"args": ["--help"], "env": {"PATH": "/"}} ### mobile: pressButton From b6f02b9caf3b7fd2bb89b5309234281368207cd5 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 16 May 2024 17:40:08 +0200 Subject: [PATCH 23/50] chore: Update dev dependencies --- package.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/package.json b/package.json index 2bb74a500..27f758292 100644 --- a/package.json +++ b/package.json @@ -157,12 +157,6 @@ "chai-as-promised": "^7.1.1", "chai-subset": "^1.6.0", "conventional-changelog-conventionalcommits": "^7.0.1", - "eslint": "^8.46.0", - "eslint-config-prettier": "^9.0.0", - "eslint-import-resolver-typescript": "^3.5.5", - "eslint-plugin-import": "^2.28.0", - "eslint-plugin-mocha": "^10.1.0", - "eslint-plugin-promise": "^6.1.1", "mocha": "^10.2.0", "mocha-junit-reporter": "^2.2.1", "mocha-multi-reporters": "^1.5.1", From f00d231d6ab0986de7c6d5fa090ec4f9f283dc80 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 16 May 2024 15:42:03 +0000 Subject: [PATCH 24/50] chore(release): 7.15.3 [skip ci] ## [7.15.3](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.2...v7.15.3) (2024-05-16) ### Miscellaneous Chores * Update dev dependencies ([b6f02b9](https://github.com/appium/appium-xcuitest-driver/commit/b6f02b9caf3b7fd2bb89b5309234281368207cd5)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acd29d087..197ec6bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.15.3](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.2...v7.15.3) (2024-05-16) + + +### Miscellaneous Chores + +* Update dev dependencies ([b6f02b9](https://github.com/appium/appium-xcuitest-driver/commit/b6f02b9caf3b7fd2bb89b5309234281368207cd5)) + ## [7.15.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.1...v7.15.2) (2024-05-07) diff --git a/package.json b/package.json index 27f758292..e4e1f5a9f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.15.2", + "version": "7.15.3", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From c1810c362ecc46e98b0cd01a196211017457c2ac Mon Sep 17 00:00:00 2001 From: KAdachi <27220367+ppken@users.noreply.github.com> Date: Tue, 21 May 2024 16:20:25 +0900 Subject: [PATCH 25/50] feat: add maxTypingFrequency to settings api (#2399) * docs: add maxTypingFrequency to settings api document * fix: update appium-webdriveragent version --- docs/reference/settings.md | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 0a14e8e3c..d7e30a7cb 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -38,3 +38,4 @@ Along with the common settings, the following driver-specific settings are avail | `safariTabBarPosition` | `string` | Handle offset of Safari tab bar in `nativeWebTap` enabled interactions. If `platformVersion` was greater than or equal to 15 and iPhone device, the value is `bottom` by default. Otherwise `top`. When the value is `top`, Appium considers offset as the bar length. iOS 15+ environment can customize the bar position in the settings app, so please adjust the offset with this. Acceptable values: `bottom`, `top` | | `useJSONSource` | `boolean` | See the description of the corresponding capability. | | `pageSourceExcludedAttributes` | `string` | One or more comma-separated attribute names to be excluded from the XML output. It might be sometimes helpful to exclude, for example, the `visible` attribute, to significantly speed-up page source retrieval. This does not affect the XML output when `useJSONSource` is enabled. Defaults to an empty string. Example: `"visible,accessible"` | +| `maxTypingFrequency` | `int` | Maximum frequency of keystrokes for typing and clear. If your tests are failing because of typing errors, you may want to adjust this. Defaults to `60` keystrokes per minute. | diff --git a/package.json b/package.json index e4e1f5a9f..71108748b 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "appium-ios-device": "^2.5.4", "appium-ios-simulator": "^6.1.2", "appium-remote-debugger": "^11.1.0", - "appium-webdriveragent": "^8.5.0", + "appium-webdriveragent": "^8.6.0", "appium-xcode": "^5.1.4", "async-lock": "^1.4.0", "asyncbox": "^3.0.0", From 480c061428db46e24583ce5cf63c3d42bcd6228c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 21 May 2024 07:22:11 +0000 Subject: [PATCH 26/50] chore(release): 7.16.0 [skip ci] ## [7.16.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.3...v7.16.0) (2024-05-21) ### Features * add maxTypingFrequency to settings api ([#2399](https://github.com/appium/appium-xcuitest-driver/issues/2399)) ([c1810c3](https://github.com/appium/appium-xcuitest-driver/commit/c1810c362ecc46e98b0cd01a196211017457c2ac)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 197ec6bf2..266aa85b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.16.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.3...v7.16.0) (2024-05-21) + + +### Features + +* add maxTypingFrequency to settings api ([#2399](https://github.com/appium/appium-xcuitest-driver/issues/2399)) ([c1810c3](https://github.com/appium/appium-xcuitest-driver/commit/c1810c362ecc46e98b0cd01a196211017457c2ac)) + ## [7.15.3](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.2...v7.15.3) (2024-05-16) diff --git a/package.json b/package.json index 71108748b..8cc620037 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.15.3", + "version": "7.16.0", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 385ed99afec1795940d8aba408ac448d73585a59 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Tue, 21 May 2024 12:03:03 +0200 Subject: [PATCH 27/50] fix: Update plist detection pattern --- lib/app-infos-cache.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/app-infos-cache.js b/lib/app-infos-cache.js index 86cc9d732..a76b5cb20 100644 --- a/lib/app-infos-cache.js +++ b/lib/app-infos-cache.js @@ -11,7 +11,7 @@ const MANIFEST_CACHE = new LRUCache({ }); const MANIFEST_FILE_NAME = 'Info.plist'; const IPA_ROOT_PLIST_PATH_PATTERN = new RegExp( - `^Payload/[^./]+\\.app/${_.escapeRegExp(MANIFEST_FILE_NAME)}$` + `^Payload/[^/]+\\.app/${_.escapeRegExp(MANIFEST_FILE_NAME)}$` ); const MAX_MANIFEST_SIZE = 1024 * 1024; // 1 MiB From b4c3b12921aba3b8b65a7a47ed8d4a33ee901626 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 21 May 2024 10:04:46 +0000 Subject: [PATCH 28/50] chore(release): 7.16.1 [skip ci] ## [7.16.1](https://github.com/appium/appium-xcuitest-driver/compare/v7.16.0...v7.16.1) (2024-05-21) ### Bug Fixes * Update plist detection pattern ([385ed99](https://github.com/appium/appium-xcuitest-driver/commit/385ed99afec1795940d8aba408ac448d73585a59)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 266aa85b6..22068b7cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.16.1](https://github.com/appium/appium-xcuitest-driver/compare/v7.16.0...v7.16.1) (2024-05-21) + + +### Bug Fixes + +* Update plist detection pattern ([385ed99](https://github.com/appium/appium-xcuitest-driver/commit/385ed99afec1795940d8aba408ac448d73585a59)) + ## [7.16.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.15.3...v7.16.0) (2024-05-21) diff --git a/package.json b/package.json index 8cc620037..ef2d5a241 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.16.0", + "version": "7.16.1", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From aab83d5924b4df606bd50b395dde1898d097f7f7 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Tue, 21 May 2024 23:53:58 -0700 Subject: [PATCH 29/50] chore: add note about .app naming finding for future reference (#2400) * chore: add note about .app naming finding for future reference * fix lint * Update app-infos-cache.js --- lib/app-infos-cache.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/app-infos-cache.js b/lib/app-infos-cache.js index a76b5cb20..25b19112e 100644 --- a/lib/app-infos-cache.js +++ b/lib/app-infos-cache.js @@ -98,6 +98,12 @@ export class AppInfosCache { let lastError; try { await zip.readEntries(ipaPath, async ({entry, extractEntryTo}) => { + // For a furutre reference: + // If the directory name includes `.app` suffix (case insensitive) like 'Payload/something.App.app/filename', + // then 'entry.fileName' would return 'Payload/something.App/filename'. + // The behavior is specific for unzip. Technically such naming is possible and valid, + // although Info.plist retrival would fail in Appium. + // https://github.com/appium/appium/issues/20075 if (!IPA_ROOT_PLIST_PATH_PATTERN.test(entry.fileName)) { return true; From e3c98950e5b7aa1f5842b46942c5958254de347a Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 22 May 2024 06:55:52 +0000 Subject: [PATCH 30/50] chore(release): 7.16.2 [skip ci] ## [7.16.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.16.1...v7.16.2) (2024-05-22) ### Miscellaneous Chores * add note about .app naming finding for future reference ([#2400](https://github.com/appium/appium-xcuitest-driver/issues/2400)) ([aab83d5](https://github.com/appium/appium-xcuitest-driver/commit/aab83d5924b4df606bd50b395dde1898d097f7f7)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22068b7cc..81ff6718a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.16.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.16.1...v7.16.2) (2024-05-22) + + +### Miscellaneous Chores + +* add note about .app naming finding for future reference ([#2400](https://github.com/appium/appium-xcuitest-driver/issues/2400)) ([aab83d5](https://github.com/appium/appium-xcuitest-driver/commit/aab83d5924b4df606bd50b395dde1898d097f7f7)) + ## [7.16.1](https://github.com/appium/appium-xcuitest-driver/compare/v7.16.0...v7.16.1) (2024-05-21) diff --git a/package.json b/package.json index ef2d5a241..127c6890a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.16.1", + "version": "7.16.2", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From acf37dd4ee20745908ff87ea48d83d4e143d63d3 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sun, 2 Jun 2024 14:59:07 +0200 Subject: [PATCH 31/50] feat: Document respectSystemAlerts setting (#2402) --- docs/guides/troubleshooting.md | 15 ++++++++++----- docs/reference/settings.md | 1 + package.json | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index b165dda37..35cfd0da0 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -21,11 +21,16 @@ System dialogs, such as permission dialogs, might not be interactable directly w Despite a similar look, dialogs belonging to the active session application (e.g. initially passed as `appium:app` or `appium:bundleId` capability value) do not require such adjustment. -XCUITest driver offers a few methods to handle them. - -- Start a session without `appium:app` nor `appium:bundleId`. Then XCUITest driver attempts to get the current active application. This requires you to start an application after a new session request with [`mobile: installApp`](../reference/execute-methods.md#mobile-installapp) to install an app if needed and [`mobile: launchApp`](../reference/execute-methods.md#mobile-launchapp)/[`mobile: activateApp`](../reference/execute-methods.md#mobile-activateapp), but it could automatically change the active application with `com.apple.springboard` or activated application on the top. (Note that the automatic detection could have a delay, thus each action could take more time.) - - When a permission alert exists on the top, it could select the `com.apple.springboard` - - When another application is on the top by accepting/denying the system alert, or [`mobile: activateApp`](../reference/execute-methods.md#mobile-activateapp), the application would be selected as an active application. +XCUITest driver offers a couple of approaches to handle them: + +- Set the [respectSystemAlerts setting](../reference/settings.md) to `true`. It enforces the active application + detection algorithm to check a presence of system alerts and to return the Springboard app if this check succeeds. + Such approach emulates the driver behavior prior to version 6 of XCUITest driver, although it might slightly + slow down your scripts because each attempt to detect an active app would require to also query for alerts + presence. +- Start a session without `appium:app` nor `appium:bundleId`. Then XCUITest driver attempts to get the current active application. This requires you to start an application after a new session request with [`mobile: installApp`](../reference/execute-methods.md#mobile-installapp) to install an app if needed and [`mobile: launchApp`](../reference/execute-methods.md#mobile-launchapp)/[`mobile: activateApp`](../reference/execute-methods.md#mobile-activateapp), but it could automatically change the active application with `com.apple.springboard` or activate an application at the foreground. (Note that the automatic app detection might be lengthy, thus each action could take more time.) + - When a permission alert exists at the foreground, it could select the `com.apple.springboard` + - When another application is at the foreground by accepting/denying the system alert, or [`mobile: activateApp`](../reference/execute-methods.md#mobile-activateapp), the application would be selected as an active application. - [`mobile: alert`](../reference/execute-methods.md#mobile-alert) - `defaultActiveApplication` setting in [Settings](../reference/settings.md). - e.g. With the [Appium Ruby client](https://github.com/appium/ruby_lib_core) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index d7e30a7cb..64ba0e6c5 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -39,3 +39,4 @@ Along with the common settings, the following driver-specific settings are avail | `useJSONSource` | `boolean` | See the description of the corresponding capability. | | `pageSourceExcludedAttributes` | `string` | One or more comma-separated attribute names to be excluded from the XML output. It might be sometimes helpful to exclude, for example, the `visible` attribute, to significantly speed-up page source retrieval. This does not affect the XML output when `useJSONSource` is enabled. Defaults to an empty string. Example: `"visible,accessible"` | | `maxTypingFrequency` | `int` | Maximum frequency of keystrokes for typing and clear. If your tests are failing because of typing errors, you may want to adjust this. Defaults to `60` keystrokes per minute. | +| `respectSystemAlerts` | `boolean` | Currently we detect the app under test as active if XCTest returns XCUIApplicationStateRunningForeground state for it. In case the app under test is covered by a system alert from the Springboard app this approach might be confusing as we cannot interact with it unless an alert is properly handled. If this setting is set to true (by default it is false) then it forces WDA to verify the presence of alerts shown by Springboard and return the latter while performing the automated app detection. It affects the performance of active app detection, but might be more convenient for writing test scripts (e.g. eliminates the need of proactive switching between system and custom apps). Also, this behavior emulates the legacy active application detection logic before version 6 of the driver. | diff --git a/package.json b/package.json index 127c6890a..5434a888d 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "appium-ios-device": "^2.5.4", "appium-ios-simulator": "^6.1.2", "appium-remote-debugger": "^11.1.0", - "appium-webdriveragent": "^8.6.0", + "appium-webdriveragent": "^8.7.0", "appium-xcode": "^5.1.4", "async-lock": "^1.4.0", "asyncbox": "^3.0.0", From 17548d1cc1d5b25a1c038fc22f7a9b16c3be266c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 2 Jun 2024 13:00:59 +0000 Subject: [PATCH 32/50] chore(release): 7.17.0 [skip ci] ## [7.17.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.16.2...v7.17.0) (2024-06-02) ### Features * Document respectSystemAlerts setting ([#2402](https://github.com/appium/appium-xcuitest-driver/issues/2402)) ([acf37dd](https://github.com/appium/appium-xcuitest-driver/commit/acf37dd4ee20745908ff87ea48d83d4e143d63d3)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ff6718a..5717bafd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.17.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.16.2...v7.17.0) (2024-06-02) + + +### Features + +* Document respectSystemAlerts setting ([#2402](https://github.com/appium/appium-xcuitest-driver/issues/2402)) ([acf37dd](https://github.com/appium/appium-xcuitest-driver/commit/acf37dd4ee20745908ff87ea48d83d4e143d63d3)) + ## [7.16.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.16.1...v7.16.2) (2024-05-22) diff --git a/package.json b/package.json index 5434a888d..e326fc144 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.16.2", + "version": "7.17.0", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 88f8f02f70d294e05a77e454c0f3df2799c27189 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Sun, 2 Jun 2024 10:20:28 -0700 Subject: [PATCH 33/50] docs: fix link in run-prebuilt-wda --- docs/guides/run-prebuilt-wda.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/run-prebuilt-wda.md b/docs/guides/run-prebuilt-wda.md index adefc6ccf..b890832cc 100644 --- a/docs/guides/run-prebuilt-wda.md +++ b/docs/guides/run-prebuilt-wda.md @@ -96,7 +96,7 @@ combination, but it could help `appium:usePrebuiltWDA` to not manage the WDA pro ## Capabilities for Prebuilt WDA with `appium:prebuiltWDAPath` -[Run Preinstalled WebDriverAgentRunner](./run-prebuilt-wda.md) provides `appium:prebuiltWDAPath` capability. +[Run Preinstalled WebDriverAgentRunner](./run-preinstalled-wda.md) provides `appium:prebuiltWDAPath` capability. It also achieves the same thing, but the `appium:prebuiltWDAPath` does not use `xcodebuild`. Please check the link for more details. From 2ef84b00da2ef8245f4d864f98cb3615a17059c2 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Sun, 2 Jun 2024 11:26:20 -0700 Subject: [PATCH 34/50] docs: fix typo in run-preinstalled-wda --- docs/guides/run-preinstalled-wda.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/run-preinstalled-wda.md b/docs/guides/run-preinstalled-wda.md index a905144a5..bf045d622 100644 --- a/docs/guides/run-preinstalled-wda.md +++ b/docs/guides/run-preinstalled-wda.md @@ -8,7 +8,7 @@ command execution, improving the session startup performance. !!! warning - iOS/tvOS 17+ speicic: + iOS/tvOS 17+ specific: This method currently works over `devicectl` for iOS 17+ with Xcode 15+ environment since XCUITest driver v7.5.0. This may not work for tvOS 17+. From e05b63ae68bab6beca808f66d814db6c4e6ba7d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 16:51:05 +0300 Subject: [PATCH 35/50] chore(deps-dev): bump sinon from 17.0.2 to 18.0.0 (#2398) Bumps [sinon](https://github.com/sinonjs/sinon) from 17.0.2 to 18.0.0. - [Release notes](https://github.com/sinonjs/sinon/releases) - [Changelog](https://github.com/sinonjs/sinon/blob/main/docs/changelog.md) - [Commits](https://github.com/sinonjs/sinon/compare/v17.0.2...v18.0.0) --- updated-dependencies: - dependency-name: sinon dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e326fc144..d2dc74b3f 100644 --- a/package.json +++ b/package.json @@ -165,7 +165,7 @@ "rimraf": "^5.0.1", "semantic-release": "^23.0.0", "sharp": "^0.x", - "sinon": "^17.0.0", + "sinon": "^18.0.0", "sinon-chai": "^3.7.0", "ts-node": "^10.9.1", "type-fest": "^4.1.0", From 508939f9fb5ebf4f6b23711e1c83c031de4d2bb1 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 3 Jun 2024 13:53:01 +0000 Subject: [PATCH 36/50] chore(release): 7.17.1 [skip ci] ## [7.17.1](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.0...v7.17.1) (2024-06-03) ### Miscellaneous Chores * **deps-dev:** bump sinon from 17.0.2 to 18.0.0 ([#2398](https://github.com/appium/appium-xcuitest-driver/issues/2398)) ([e05b63a](https://github.com/appium/appium-xcuitest-driver/commit/e05b63ae68bab6beca808f66d814db6c4e6ba7d8)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5717bafd4..2c582166d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.17.1](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.0...v7.17.1) (2024-06-03) + + +### Miscellaneous Chores + +* **deps-dev:** bump sinon from 17.0.2 to 18.0.0 ([#2398](https://github.com/appium/appium-xcuitest-driver/issues/2398)) ([e05b63a](https://github.com/appium/appium-xcuitest-driver/commit/e05b63ae68bab6beca808f66d814db6c4e6ba7d8)) + ## [7.17.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.16.2...v7.17.0) (2024-06-02) diff --git a/package.json b/package.json index d2dc74b3f..70fc6b939 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.17.0", + "version": "7.17.1", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 4058b4c33687b11bdc90b3a22acd67330aaab46c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 22:40:18 -0700 Subject: [PATCH 37/50] chore(deps-dev): bump semantic-release from 23.1.1 to 24.0.0 and conventional-changelog-conventionalcommits to 8.0.0 (#2403) * chore(deps-dev): bump semantic-release from 23.1.1 to 24.0.0 Bumps [semantic-release](https://github.com/semantic-release/semantic-release) from 23.1.1 to 24.0.0. - [Release notes](https://github.com/semantic-release/semantic-release/releases) - [Commits](https://github.com/semantic-release/semantic-release/compare/v23.1.1...v24.0.0) --- updated-dependencies: - dependency-name: semantic-release dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Update package.json --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kazuaki Matsuo --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 70fc6b939..c9f3f6f3c 100644 --- a/package.json +++ b/package.json @@ -156,14 +156,14 @@ "chai": "^4.3.7", "chai-as-promised": "^7.1.1", "chai-subset": "^1.6.0", - "conventional-changelog-conventionalcommits": "^7.0.1", + "conventional-changelog-conventionalcommits": "^8.0.0", "mocha": "^10.2.0", "mocha-junit-reporter": "^2.2.1", "mocha-multi-reporters": "^1.5.1", "pem": "^1.14.8", "prettier": "^3.0.0", "rimraf": "^5.0.1", - "semantic-release": "^23.0.0", + "semantic-release": "^24.0.0", "sharp": "^0.x", "sinon": "^18.0.0", "sinon-chai": "^3.7.0", From 1569b4e44e158bcf0536a39ce7c6de0923847db5 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 4 Jun 2024 05:42:06 +0000 Subject: [PATCH 38/50] chore(release): 7.17.2 [skip ci] ## [7.17.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.1...v7.17.2) (2024-06-04) ### Miscellaneous Chores * **deps-dev:** bump semantic-release from 23.1.1 to 24.0.0 and conventional-changelog-conventionalcommits to 8.0.0 ([#2403](https://github.com/appium/appium-xcuitest-driver/issues/2403)) ([4058b4c](https://github.com/appium/appium-xcuitest-driver/commit/4058b4c33687b11bdc90b3a22acd67330aaab46c)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c582166d..3533586b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [7.17.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.1...v7.17.2) (2024-06-04) + +### Miscellaneous Chores + +* **deps-dev:** bump semantic-release from 23.1.1 to 24.0.0 and conventional-changelog-conventionalcommits to 8.0.0 ([#2403](https://github.com/appium/appium-xcuitest-driver/issues/2403)) ([4058b4c](https://github.com/appium/appium-xcuitest-driver/commit/4058b4c33687b11bdc90b3a22acd67330aaab46c)) + ## [7.17.1](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.0...v7.17.1) (2024-06-03) diff --git a/package.json b/package.json index c9f3f6f3c..944127a7d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.17.1", + "version": "7.17.2", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From bf2a83453fc5421b40f324a2a760de83dfdf490a Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Tue, 4 Jun 2024 17:17:24 -0700 Subject: [PATCH 39/50] docs: address real devices in docs/guides/run-preinstalled-wda (#2404) --- docs/guides/run-preinstalled-wda.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/run-preinstalled-wda.md b/docs/guides/run-preinstalled-wda.md index bf045d622..51912e7f5 100644 --- a/docs/guides/run-preinstalled-wda.md +++ b/docs/guides/run-preinstalled-wda.md @@ -58,7 +58,7 @@ The app can then be installed without `xcodebuild` using the 3rd party tools. ### Additional requirement for iOS 17+/tvOS17+ -To launch the WebDriverAgentRunner package with `xcrun devicectl device process launch` it should not have `Frameworks/XC**` files. +To launch the WebDriverAgentRunner package with `xcrun devicectl device process launch` for real devices it should not have `Frameworks/XC**` files. For example, after building the WebDriverAgent with Xcode with proper sign, it generates `/Users//Library/Developer/Xcode/DerivedData/WebDriverAgent-ezumztihszjoxgacuhatrhxoklbh/Build/Products/Debug-appletvos/WebDriverAgentRunner-Runner.app`. Then you can remove `Frameworks/XC**` in `WebDriverAgentRunner-Runner.app` like `rm Frameworks/WebDriverAgentRunner-Runner.app/XC**`. From 453fe680e0da7988821e50d9779bbec2763371fc Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Wed, 5 Jun 2024 00:20:04 -0700 Subject: [PATCH 40/50] fix: system prompt for Apple ID sign translation for simulators (#2405) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 944127a7d..a3aa1d4d0 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@colors/colors": "^1.6.0", "appium-idb": "^1.6.13", "appium-ios-device": "^2.5.4", - "appium-ios-simulator": "^6.1.2", + "appium-ios-simulator": "^6.1.7", "appium-remote-debugger": "^11.1.0", "appium-webdriveragent": "^8.7.0", "appium-xcode": "^5.1.4", From 679d4284596caa26d4d4f263fafe077af260d0ff Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 5 Jun 2024 07:21:47 +0000 Subject: [PATCH 41/50] chore(release): 7.17.3 [skip ci] ## [7.17.3](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.2...v7.17.3) (2024-06-05) ### Bug Fixes * system prompt for Apple ID sign translation for simulators ([#2405](https://github.com/appium/appium-xcuitest-driver/issues/2405)) ([453fe68](https://github.com/appium/appium-xcuitest-driver/commit/453fe680e0da7988821e50d9779bbec2763371fc)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3533586b3..93b393a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [7.17.3](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.2...v7.17.3) (2024-06-05) + +### Bug Fixes + +* system prompt for Apple ID sign translation for simulators ([#2405](https://github.com/appium/appium-xcuitest-driver/issues/2405)) ([453fe68](https://github.com/appium/appium-xcuitest-driver/commit/453fe680e0da7988821e50d9779bbec2763371fc)) + ## [7.17.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.1...v7.17.2) (2024-06-04) ### Miscellaneous Chores diff --git a/package.json b/package.json index a3aa1d4d0..3e9801b6b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.17.2", + "version": "7.17.3", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 28a75efb63e699bf62c73710a6eb8c34abb59d0d Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 7 Jun 2024 10:16:39 +0200 Subject: [PATCH 42/50] fix: Add proper timestamps to server logs (#2406) --- lib/commands/log.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/commands/log.js b/lib/commands/log.js index 8699eb83b..6ced42692 100644 --- a/lib/commands/log.js +++ b/lib/commands/log.js @@ -52,7 +52,7 @@ const SUPPORTED_LOG_TYPES = { getter: (self) => { self.assertFeatureEnabled(GET_SERVER_LOGS_FEATURE); return log.unwrap().record.map((x) => ({ - timestamp: Date.now(), + timestamp: /** @type {any} */ (x).timestamp ?? Date.now(), level: 'ALL', message: _.isEmpty(x.prefix) ? x.message : `[${x.prefix}] ${x.message}`, })); From 3f00ca570722fed88173226ca64eba4c767bfc29 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 7 Jun 2024 08:18:22 +0000 Subject: [PATCH 43/50] chore(release): 7.17.4 [skip ci] ## [7.17.4](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.3...v7.17.4) (2024-06-07) ### Bug Fixes * Add proper timestamps to server logs ([#2406](https://github.com/appium/appium-xcuitest-driver/issues/2406)) ([28a75ef](https://github.com/appium/appium-xcuitest-driver/commit/28a75efb63e699bf62c73710a6eb8c34abb59d0d)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b393a88..07bc776d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [7.17.4](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.3...v7.17.4) (2024-06-07) + +### Bug Fixes + +* Add proper timestamps to server logs ([#2406](https://github.com/appium/appium-xcuitest-driver/issues/2406)) ([28a75ef](https://github.com/appium/appium-xcuitest-driver/commit/28a75efb63e699bf62c73710a6eb8c34abb59d0d)) + ## [7.17.3](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.2...v7.17.3) (2024-06-05) ### Bug Fixes diff --git a/package.json b/package.json index 3e9801b6b..7ae5b1f95 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.17.3", + "version": "7.17.4", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 805c29a580486c725d040bff97e736769a53f7cb Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Tue, 11 Jun 2024 20:56:54 +0200 Subject: [PATCH 44/50] docs: Describe capability sets for common testing scenarios (#2408) --- docs/guides/capability-sets.md | 169 +++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 170 insertions(+) create mode 100644 docs/guides/capability-sets.md diff --git a/docs/guides/capability-sets.md b/docs/guides/capability-sets.md new file mode 100644 index 000000000..765568fe9 --- /dev/null +++ b/docs/guides/capability-sets.md @@ -0,0 +1,169 @@ +--- +title: Basic Examples of Session Capability Sets +--- + +This article describes necessary capabilities that must be provided in order +to implement some common automation testing scenarios. +It only describes very minimum sets of capabilities required to +be included. For refined setups more of them might need to be provided. Check the +[Capabilities](../reference/capabilities.md) article for more details +on each option available for the fine-tuning of XCUITest driver sessions. + +### Application File (Real Device) + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:platformVersion": "", + "appium:udid": "", + "appium:app": "/path/to/local/package.ipa" +} +``` + +`appium:app` could also be a remote app or an archive: + +``` + "appium:app": "https://example.com/package.ipa" + "appium:app": "https://example.com/package.zip" +``` + +`appium:udid` could also be set to `auto` in order to select the first matched device +connected to the host (or a single one if only one is connected): + +``` + "appium:udid": "auto" +``` + +### Application File (Simulator) + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:deviceName": "", + "appium:platformVersion": "", + "appium:app": "/path/to/local/package.app" +} +``` + +`appium:app` could also be an archive: + +``` + "appium:app": "https://example.com/package.zip" + "appium:app": "/path/to/local/package.zip" +``` + +### Safari (Real Device) + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "browserName": "Safari", + "appium:platformVersion": "", + "appium:udid": "" +} +``` + +You may also provide `appium:safariInitialUrl` capability value to navigate +to the desired page during the session startup: + +``` + "appium:safariInitialUrl": "https://server.com/page" +``` + +### Safari (Simulator) + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "browserName": "Safari", + "appium:deviceName": "", + "appium:platformVersion": "" +} +``` + +### Pre-Installed App (Real Device) + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:platformVersion": "", + "appium:udid": "", + "appium:bundleId": "", + "appium:noReset": true +} +``` + +The `appium:noReset` capability is set to `true` in order to tell the driver +the app identified by `appium:bundleId` is already preinstalled and must not be reset. + +### Pre-Installed App (Simulator) + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:deviceName": "", + "appium:platformVersion": "", + "appium:bundleId": "", + "appium:noReset": true +} +``` + +### Deeplink (Real Device running iOS 17+) + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:platformVersion": "", + "appium:udid": "", + "appium:initialDeeplinkUrl": "" +} +``` + +### Deeplink (Simulator running iOS 17+) + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:deviceName": "", + "appium:platformVersion": "", + "appium:initialDeeplinkUrl": "" +} +``` + +### Custom Launch (Real Device) + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:platformVersion": "", + "appium:udid": "", +} +``` + +This will start your test at the Home screen. +Afterwards you may use any of the application management +methods, like [mobile: installApp](../reference//execute-methods.md#mobile-installapp) +or [mobile: activateApp](../reference//execute-methods.md#mobile-activateapp) +to manage the life cycle of your app or switch between contexts to +manage web pages. Check the full list of +[mobile: execute methods](../reference/execute-methods.md) for more details. + +### Custom Launch (Simulator) + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:deviceName": "", + "appium:platformVersion": "" +} +``` diff --git a/mkdocs.yml b/mkdocs.yml index ba7c611e6..3c7fba64d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,7 @@ nav: - guides/tvos.md - guides/input-events.md - guides/troubleshooting.md + - guides/capability-sets.md - contributing.md plugins: From b2f57b7fd7cce340969f522203d9375d3b120cdc Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Wed, 12 Jun 2024 00:28:24 -0700 Subject: [PATCH 45/50] fix: stream end after write in a file push (#2409) --- lib/ios-fs-helpers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ios-fs-helpers.js b/lib/ios-fs-helpers.js index 27c998a7b..3aecaeeb4 100644 --- a/lib/ios-fs-helpers.js +++ b/lib/ios-fs-helpers.js @@ -198,6 +198,7 @@ export async function pushFile (afcService, localPathOrPayload, remotePath, opts }); if (Buffer.isBuffer(source)) { writeStream.write(source); + writeStream.end(); } else { source.pipe(writeStream); } From f00139e68fe29ba53f21b9d3293dbd47c33d3c1e Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 12 Jun 2024 07:30:06 +0000 Subject: [PATCH 46/50] chore(release): 7.17.5 [skip ci] ## [7.17.5](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.4...v7.17.5) (2024-06-12) ### Bug Fixes * stream end after write in a file push ([#2409](https://github.com/appium/appium-xcuitest-driver/issues/2409)) ([b2f57b7](https://github.com/appium/appium-xcuitest-driver/commit/b2f57b7fd7cce340969f522203d9375d3b120cdc)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07bc776d0..95eb38629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [7.17.5](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.4...v7.17.5) (2024-06-12) + +### Bug Fixes + +* stream end after write in a file push ([#2409](https://github.com/appium/appium-xcuitest-driver/issues/2409)) ([b2f57b7](https://github.com/appium/appium-xcuitest-driver/commit/b2f57b7fd7cce340969f522203d9375d3b120cdc)) + ## [7.17.4](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.3...v7.17.4) (2024-06-07) ### Bug Fixes diff --git a/package.json b/package.json index 7ae5b1f95..a8cfe15be 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.17.4", + "version": "7.17.5", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 42bc4f9a373126b0025fa5cec60ee2107d101d53 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Tue, 18 Jun 2024 11:38:53 -0700 Subject: [PATCH 47/50] fix: relax the max of recording limitation to 4200 sec as timeLimit (#2410) * fix: relax the max of recording limitation to 4200 sec as timeLimit --- lib/commands/recordscreen.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/commands/recordscreen.js b/lib/commands/recordscreen.js index f1d4e3749..ec2efc481 100644 --- a/lib/commands/recordscreen.js +++ b/lib/commands/recordscreen.js @@ -7,7 +7,12 @@ import {WDA_BASE_URL} from 'appium-webdriveragent'; import {waitForCondition} from 'asyncbox'; import url from 'url'; -const MAX_RECORDING_TIME_SEC = 60 * 30; +/** + * Set max timeout for 'reconnect_delay_max' ffmpeg argument usage. + * It could have [0 - 4294] range limitation thus this value should be less than that right now + * to return a better error message. + */ +const MAX_RECORDING_TIME_SEC = 4200; const DEFAULT_RECORDING_TIME_SEC = 60 * 3; const DEFAULT_MJPEG_SERVER_PORT = 9100; const DEFAULT_FPS = 10; From cd25bad48efca8098672196fa1aa30a4eb189dc6 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 18 Jun 2024 18:40:44 +0000 Subject: [PATCH 48/50] chore(release): 7.17.6 [skip ci] ## [7.17.6](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.5...v7.17.6) (2024-06-18) ### Bug Fixes * relax the max of recording limitation to 4200 sec as timeLimit ([#2410](https://github.com/appium/appium-xcuitest-driver/issues/2410)) ([42bc4f9](https://github.com/appium/appium-xcuitest-driver/commit/42bc4f9a373126b0025fa5cec60ee2107d101d53)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95eb38629..662dea97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [7.17.6](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.5...v7.17.6) (2024-06-18) + +### Bug Fixes + +* relax the max of recording limitation to 4200 sec as timeLimit ([#2410](https://github.com/appium/appium-xcuitest-driver/issues/2410)) ([42bc4f9](https://github.com/appium/appium-xcuitest-driver/commit/42bc4f9a373126b0025fa5cec60ee2107d101d53)) + ## [7.17.5](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.4...v7.17.5) (2024-06-12) ### Bug Fixes diff --git a/package.json b/package.json index a8cfe15be..e02242d3e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.17.5", + "version": "7.17.6", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { From 2517bf75d0de0fd00937c4c12c6ca890a49ef218 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Thu, 20 Jun 2024 09:26:26 -0700 Subject: [PATCH 49/50] feat: add pageLoadStrategy for Safari/WebView (#2411) * feat: add pageLoadStrategy * bump remote debugger * Update capabilities.md --- docs/reference/capabilities.md | 1 + lib/commands/context.js | 1 + lib/desired-caps.js | 4 ++++ package.json | 2 +- 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 37b808f70..61d79a29b 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -121,6 +121,7 @@ about capabilities, refer to the [Appium documentation](https://appium.io/docs/e |
Capability
|Description|
Example
| |----------|-----------|------| +|`pageLoadStrategy` | One of the available page load strategies. See https://www.w3.org/TR/webdriver/#capabilities. Default `normal`. | `eager` | |`appium:absoluteWebLocations`|This capability will direct the `Get Element Location` command, when used within webviews, to return coordinates which are relative to the origin of the page, rather than relative to the current scroll offset. This capability has no effect outside of webviews. Default `false`.|`true`| |`appium:safariGarbageCollect`|Turns on/off Web Inspector garbage collection when executing scripts on Safari. Turning on may improve performance. Defaults to `false`.|`true` or `false`| |`appium:includeSafariInWebviews`|Add Safari web contexts to the list of contexts available during a native/webview app test. This is useful if the test opens Safari and needs to be able to interact with it. Defaults to `false`.|`true` or `false`| diff --git a/lib/commands/context.js b/lib/commands/context.js index 68d60acf2..8d64ebf7d 100644 --- a/lib/commands/context.js +++ b/lib/commands/context.js @@ -460,6 +460,7 @@ const helpers = { logAllCommunicationHexDump: this.opts.safariLogAllCommunicationHexDump, socketChunkSize: this.opts.safariSocketChunkSize, webInspectorMaxFrameLength: this.opts.safariWebInspectorMaxFrameLength, + pageLoadStrategy: this.caps.pageLoadStrategy, }, this.isRealDevice(), ); diff --git a/lib/desired-caps.js b/lib/desired-caps.js index a5ecdf60c..6c371f0e9 100644 --- a/lib/desired-caps.js +++ b/lib/desired-caps.js @@ -380,6 +380,10 @@ const desiredCapConstraints = /** @type {const} */ ({ appTimeZone: { isString: true, }, + pageLoadStrategy: { + isString: true, + inclusionCaseInsensitive: ['none', 'eager', 'normal'] + } }); export {desiredCapConstraints, PLATFORM_NAME_IOS, PLATFORM_NAME_TVOS}; diff --git a/package.json b/package.json index e02242d3e..8fc395232 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "appium-idb": "^1.6.13", "appium-ios-device": "^2.5.4", "appium-ios-simulator": "^6.1.7", - "appium-remote-debugger": "^11.1.0", + "appium-remote-debugger": "^11.3.0", "appium-webdriveragent": "^8.7.0", "appium-xcode": "^5.1.4", "async-lock": "^1.4.0", From 8fc4d8ad7aa0769d3fd06f0977063b53914a50b7 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 20 Jun 2024 16:28:43 +0000 Subject: [PATCH 50/50] chore(release): 7.18.0 [skip ci] ## [7.18.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.6...v7.18.0) (2024-06-20) ### Features * add pageLoadStrategy for Safari/WebView ([#2411](https://github.com/appium/appium-xcuitest-driver/issues/2411)) ([2517bf7](https://github.com/appium/appium-xcuitest-driver/commit/2517bf75d0de0fd00937c4c12c6ca890a49ef218)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 662dea97c..60b06991e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [7.18.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.6...v7.18.0) (2024-06-20) + +### Features + +* add pageLoadStrategy for Safari/WebView ([#2411](https://github.com/appium/appium-xcuitest-driver/issues/2411)) ([2517bf7](https://github.com/appium/appium-xcuitest-driver/commit/2517bf75d0de0fd00937c4c12c6ca890a49ef218)) + ## [7.17.6](https://github.com/appium/appium-xcuitest-driver/compare/v7.17.5...v7.17.6) (2024-06-18) ### Bug Fixes diff --git a/package.json b/package.json index 8fc395232..bd132b070 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.17.6", + "version": "7.18.0", "author": "Appium Contributors", "license": "Apache-2.0", "repository": {