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: 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d624cb65f..60b06991e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,144 @@ +## [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 + +* 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 + +* 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 + +* 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 + +* 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 + +* **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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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/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/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. diff --git a/docs/guides/run-preinstalled-wda.md b/docs/guides/run-preinstalled-wda.md index a905144a5..51912e7f5 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+. @@ -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**`. 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/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/capabilities.md b/docs/reference/capabilities.md index d652aaad0..61d79a29b 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -35,8 +35,8 @@ 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: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:appPushTimeout` | The timeout for an application install/upgrade in milliseconds. Works for real devices only. The default value is `480000` ms (8 minutes) | +| **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 @@ -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`| @@ -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`| @@ -145,6 +146,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/docs/reference/execute-methods.md b/docs/reference/execute-methods.md index c2ab955bd..1eae5f7be 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 @@ -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 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/docs/reference/settings.md b/docs/reference/settings.md index 0a14e8e3c..64ba0e6c5 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -38,3 +38,5 @@ 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. | +| `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/lib/app-infos-cache.js b/lib/app-infos-cache.js new file mode 100644 index 000000000..25b19112e --- /dev/null +++ b/lib/app-infos-cache.js @@ -0,0 +1,187 @@ +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 IPA_ROOT_PLIST_PATH_PATTERN = new RegExp( + `^Payload/[^/]+\\.app/${_.escapeRegExp(MANIFEST_FILE_NAME)}$` +); +const MAX_MANIFEST_SIZE = 1024 * 1024; // 1 MiB + +export class AppInfosCache { + /** + * @param {import('@appium/types').AppiumLogger} log + */ + constructor (log) { + this.log = log; + } + + /** + * + * @param {string} bundlePath Full path to the .ipa or .app bundle + * @param {string} propertyName + * @returns {Promise} + */ + async extractManifestProperty (bundlePath, propertyName) { + const result = (await this.put(bundlePath))[propertyName]; + this.log.debug(`${propertyName}: ${JSON.stringify(result)}`); + return result; + } + + /** + * + * @param {string} bundlePath Full path to the .ipa or .app bundle + * @returns {Promise} + */ + async extractBundleId (bundlePath) { + return await this.extractManifestProperty(bundlePath, 'CFBundleIdentifier'); + } + + /** + * + * @param {string} bundlePath Full path to the .ipa or .app bundle + * @returns {Promise} + */ + async extractBundleVersion (bundlePath) { + return await this.extractManifestProperty(bundlePath, 'CFBundleVersion'); + } + + /** + * + * @param {string} bundlePath Full path to the .ipa or .app bundle + * @returns {Promise} + */ + async extractAppPlatforms (bundlePath) { + const result = await this.extractManifestProperty(bundlePath, 'CFBundleSupportedPlatforms'); + if (!Array.isArray(result)) { + throw new Error(`${path.basename(bundlePath)}': CFBundleSupportedPlatforms is not a valid list`); + } + return result; + } + + /** + * + * @param {string} bundlePath Full path to the .ipa or .app bundle + * @returns {Promise} + */ + async extractExecutableName (bundlePath) { + return await this.extractManifestProperty(bundlePath, 'CFBundleExecutable'); + } + + /** + * + * @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 (bundlePath) { + return (await fs.stat(bundlePath)).isFile() + ? await this._putIpa(bundlePath) + : await this._putApp(bundlePath); + } + + /** + * @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}) => { + // 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; + } + + 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 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}`; + } + throw new Error(errorMessage); + } + return manifestPayload; + } + + /** + * @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([ + this._readPlist(manifestPath, appPath), + 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; + } + + /** + * @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?`); + } + } +} diff --git a/lib/app-utils.js b/lib/app-utils.js index dda29ce53..ea9a6b25e 100644 --- a/lib/app-utils.js +++ b/lib/app-utils.js @@ -1,19 +1,20 @@ 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'; +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'], @@ -22,146 +23,67 @@ const SAFARI_OPTS_ALIASES_MAP = /** @type {const} */ ({ safariIgnoreFraudWarning: [['WarnAboutFraudulentWebsites'], (x) => Number(!x)], safariOpenLinksInBackground: [['OpenLinksInBackground'], (x) => Number(Boolean(x))], }); - - -/** - * 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 MAX_ARCHIVE_SCAN_DEPTH = 1; +export const SUPPORTED_EXTENSIONS = [IPA_EXT, APP_EXT]; +const MACOS_RESOURCE_FOLDER = '__MACOSX'; +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'); +export async function verifyApplicationPlatform() { + this.log.debug('Verifying application platform'); - const supportedPlatforms = await fetchSupportedAppPlatforms(app); - log.debug(`CFBundleSupportedPlatforms: ${JSON.stringify(supportedPlatforms)}`); - - 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 = {}; @@ -171,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); + } + } } /** @@ -252,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() && @@ -261,41 +217,249 @@ export async function isAppBundle(appPath) { } /** - * Extract the given archive and looks for items with given extensions in it + * 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 + * @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, + }; +} + +/** + * 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 + * + * @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; } /** * 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; } /** @@ -318,3 +482,190 @@ export function buildSafariPreferences(opts) { } return safariSettings; } + +/** + * Unzip the given archive and find a matching .app bundle in it + * + * @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. + * @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 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(/** @type {string} */ (appPathOrZipStream))); + } else { + if (depth > 0) { + assert.fail('Streaming unzip cannot be invoked for nested archive items'); + } + ({rootDir, archiveSize} = await unzipStream( + /** @type {import('node:stream').Readable} */ (appPathOrZipStream)) + ); + } + } catch (e) { + this.log.debug(e.stack); + throw new Error( + `Cannot prepare the application for testing. Original error: ${e.message}` + ); + } + const secondsElapsed = timer.getDuration().asSeconds; + this.log.info( + `The file (${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 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`); + } 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) || (this.isRealDevice() && await isIpaBundle(fullPath))) + && await isCompatibleWithCurrentPlatform(fullPath) + ) { + this.log.debug(`Selecting the application at '${matchedPath}'`); + return await isolateApp(fullPath); + } + } + } finally { + await fs.rimraf(rootDir); + } + throw new Error(errMsg); +} + +/** + * 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, headers}) { + return this.isRealDevice() + ? await downloadIpa.bind(this)(stream, headers) + : await unzipApp.bind(this)(stream); +} + +/** + * @this {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; + + 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: /** @type {string} */ (cachedPath)}; + } + + 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: 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/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/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/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/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}`, })); 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; 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 f7e291b92..6c371f0e9 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, @@ -352,6 +355,7 @@ const desiredCapConstraints = /** @type {const} */ ({ isBoolean: true, }, appInstallStrategy: { + deprecated: true, isString: true, inclusionCaseInsensitive: ['serial', 'parallel', 'ios-deploy'], }, @@ -361,6 +365,9 @@ const desiredCapConstraints = /** @type {const} */ ({ skipTriggerInputEventAfterSendkeys: { isBoolean: true, }, + sendKeyStrategy: { + isString: true, + }, skipSyncUiDialogTranslation: { isBoolean: true, }, @@ -373,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/lib/driver.js b/lib/driver.js index 34c094206..f2ce62049 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -12,15 +12,10 @@ 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'; @@ -58,7 +53,6 @@ import { getAndCheckXcodeVersion, getDriverInfo, isLocalHost, - isTvOs, markSystemFilesForCleanup, normalizeCommandTimeouts, normalizePlatformVersion, @@ -66,12 +60,11 @@ 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'; -const SUPPORTED_EXTENSIONS = [IPA_EXT, APP_EXT]; -const MAX_ARCHIVE_SCAN_DEPTH = 1; const defaultServerCaps = { webStorageEnabled: false, locationContextEnabled: false, @@ -315,6 +308,7 @@ export class XCUITestDriver extends BaseDriver { } this.lifecycleData = {}; this._audioRecorder = null; + this.appInfosCache = new AppInfosCache(this.log); } async onSettingsUpdate(key, value) { @@ -553,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); } } @@ -1065,107 +1059,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; @@ -1603,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 { @@ -1660,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) { @@ -1671,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, { @@ -1707,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)( @@ -1718,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 { @@ -1789,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}'. ` + @@ -1805,7 +1703,6 @@ export class XCUITestDriver extends BaseDriver { { skipUninstall: true, timeout: this.opts.appPushTimeout, - strategy: this.opts.appInstallStrategy, }, ); } else { @@ -1951,6 +1848,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/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 248df25d2..3aecaeeb4 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,72 @@ 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); + writeStream.end(); + } 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 +231,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 +276,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); @@ -265,28 +302,36 @@ 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) { 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); + 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); + await _pushFile(relativeFilePath); + const elapsedMs = timer.getDuration().asMilliSeconds; + if (elapsedMs > timeoutMs) { + throw new B.TimeoutError(`Timed out after ${elapsedMs} ms`); + } } } @@ -296,5 +341,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 d8463634c..3720b0faf 100644 --- a/lib/real-device-management.js +++ b/lib/real-device-management.js @@ -2,12 +2,13 @@ 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,16 +26,21 @@ export async function installToRealDevice(app, bundleId, opts = {}) { return; } - const {skipUninstall, strategy, timeout} = opts; + const { + skipUninstall, + 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); + 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. @@ -55,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 7e9fee689..c87da56ce 100644 --- a/lib/real-device.js +++ b/lib/real-device.js @@ -1,24 +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_TIMEOUT_MS = 4 * 60 * 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} @@ -27,6 +19,17 @@ 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 + * @property {boolean} isUpgrade Whether it is an app upgrade or a new install + */ + export class RealDevice { /** * @param {string} udid @@ -66,83 +69,59 @@ 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: timeout ?? IOS_DEPLOY_TIMEOUT_MS, - }); - } 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); - try { - const bundleId = await extractBundleId(app); - const bundlePathOnPhone = path.join(INSTALLATION_STAGING_DIR, bundleId); - await pushFolder(afcService, app, bundlePathOnPhone, { - timeoutMs: timeout, - enableParallelPush: - _.toLower(/** @type {'parallel'} */ (strategy)) === APP_INSTALL_STRATEGY.PARALLEL, + 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, - 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.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` + ); } /** * @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 +132,17 @@ 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.toFixed(0)} 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.toFixed(0)} ms` + ); + await installationService.installApplication(bundlePathOnPhone, clientOptions, timeout); } try { await appInstalledNotification.timeout( @@ -166,7 +151,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(); @@ -176,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/mkdocs.yml b/mkdocs.yml index e2364d0b6..3c7fba64d 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 @@ -72,6 +73,7 @@ nav: - guides/tvos.md - guides/input-events.md - guides/troubleshooting.md + - guides/capability-sets.md - contributing.md plugins: diff --git a/package.json b/package.json index 3c8d01bae..bd132b070 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.11.1", + "version": "7.18.0", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { @@ -79,9 +79,9 @@ "@colors/colors": "^1.6.0", "appium-idb": "^1.6.13", "appium-ios-device": "^2.5.4", - "appium-ios-simulator": "^6.1.2", - "appium-remote-debugger": "^11.0.0", - "appium-webdriveragent": "^8.5.0", + "appium-ios-simulator": "^6.1.7", + "appium-remote-debugger": "^11.3.0", + "appium-webdriveragent": "^8.7.0", "appium-xcode": "^5.1.4", "async-lock": "^1.4.0", "asyncbox": "^3.0.0", @@ -131,7 +131,7 @@ "singleQuote": true }, "peerDependencies": { - "appium": "^2.4.1" + "appium": "^2.5.4" }, "devDependencies": { "@appium/docutils": "^1.0.2", @@ -152,29 +152,20 @@ "@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", "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", + "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": "^17.0.0", + "sinon": "^18.0.0", "sinon-chai": "^3.7.0", "ts-node": "^10.9.1", "type-fest": "^4.1.0", 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/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); + } + }); + }); +}); 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, '😀']); + }); }); }); }); 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;