diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 4f1aa7686..4291078fd 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -12,18 +12,18 @@ jobs: fail-fast: false matrix: test_targets: - - HOST_OS: 'macos-15' - XCODE_VERSION: '26.1' - IOS_VERSION: '26.1' - IOS_MODEL: iPhone 17 + - HOST_OS: 'macos-26' + XCODE_VERSION: '26.4' + IOS_VERSION: '26.4' + IOS_MODEL: 'iPhone 17' - HOST_OS: 'macos-15' XCODE_VERSION: '16.4' - IOS_VERSION: '18.4' - IOS_MODEL: iPhone 16 Plus + IOS_VERSION: '18.5' + IOS_MODEL: 'iPhone 16 Plus' - HOST_OS: 'macos-14' XCODE_VERSION: '15.4' IOS_VERSION: '17.5' - IOS_MODEL: iPhone 15 Plus + IOS_MODEL: 'iPhone 15 Plus' # https://github.com/actions/runner-images/blob/main/images/macos/macos-14-Readme.md runs-on: ${{matrix.test_targets.HOST_OS}} @@ -46,6 +46,7 @@ jobs: DEVICE_NAME: ${{matrix.test_targets.IOS_MODEL}} PLATFORM_VERSION: ${{matrix.test_targets.IOS_VERSION}} run: | + xcrun simctl list devices available open -Fn "$(xcode-select -p)/Applications/Simulator.app" udid=$(xcrun simctl list devices available -j | \ node -p "Object.entries(JSON.parse(fs.readFileSync(0)).devices).filter((x) => x[0].includes('$PLATFORM_VERSION'.replace('.', '-'))).reduce((acc, x) => [...acc, ...x[1]], []).find(({name}) => name === '$DEVICE_NAME').udid") diff --git a/.github/workflows/publish.js.yml b/.github/workflows/publish.js.yml index 29d2a11a6..250a42e3e 100644 --- a/.github/workflows/publish.js.yml +++ b/.github/workflows/publish.js.yml @@ -15,8 +15,12 @@ permissions: id-token: write # to enable use of OIDC for trusted publishing and npm provenance env: + # DO NOT USE 26.4+ for a while since it could drop lower iOS versions forcefully + # while the project config allows such lower versions. + # (at least WDA failed to start on iOS 15) + # Xcode 26.3 looks like it's working as expected. XCODE_VERSION: '16.4' - # Available destination for simulators depend on Xcode version. + # Available destination for simulators depends on Xcode version. DESTINATION_SIM: platform=iOS Simulator,name=iPhone 17 DESTINATION_SIM_TVOS: platform=tvOS Simulator,name=Apple TV 4K (3rd generation) @@ -54,7 +58,7 @@ jobs: with: xcode-version: "${{ env.XCODE_VERSION }}" - name: ${{ matrix.config.name }} - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 diff --git a/.gitignore b/.gitignore index 5840515c7..1f5672e65 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ build/ clang/ DerivedData +wdaBuild/ ## Various settings *.pbxuser diff --git a/CHANGELOG.md b/CHANGELOG.md index 853c13024..12c1ec8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,176 @@ +## [15.0.0](https://github.com/appium/WebDriverAgent/compare/v14.2.1...v15.0.0) (2026-06-22) + +### ⚠ BREAKING CHANGES + +* AppleDevice is now abstract and only contains udid; it no longer exposes simctl or devicectl. +* Preinstalled WDA launch/terminate no longer falls back to package-owned simctl or devicectl behavior. Callers must provide hostOps.simulator or hostOps.realDevicePreinstalled for those flows. + +### Features + +* Abstract out platform-specific actions ([#1160](https://github.com/appium/WebDriverAgent/issues/1160)) ([890d32b](https://github.com/appium/WebDriverAgent/commit/890d32b4ac3fa881784dacc012650d58274941c8)) + +## [14.2.1](https://github.com/appium/WebDriverAgent/compare/v14.2.0...v14.2.1) (2026-06-19) + +### Miscellaneous Chores + +* **deps-dev:** bump @types/node from 25.9.4 to 26.0.0 ([#1159](https://github.com/appium/WebDriverAgent/issues/1159)) ([93d7043](https://github.com/appium/WebDriverAgent/commit/93d704317483eb9c29a2d46070a6a2c2943ae014)) + +## [14.2.0](https://github.com/appium/WebDriverAgent/compare/v14.1.1...v14.2.0) (2026-06-18) + +### Features + +* Limit the maximum request body size ([#1158](https://github.com/appium/WebDriverAgent/issues/1158)) ([9ea244b](https://github.com/appium/WebDriverAgent/commit/9ea244b29e3c2b160079a620fdf4ef445a3c1e38)) + +## [14.1.1](https://github.com/appium/WebDriverAgent/compare/v14.1.0...v14.1.1) (2026-06-10) + +### Miscellaneous Chores + +* address runtime version rather than Xcode for selector based methods ([#1154](https://github.com/appium/WebDriverAgent/issues/1154)) ([6618b0b](https://github.com/appium/WebDriverAgent/commit/6618b0b6ccd06c69fc9e4a0947ef0c88c89b1e48)) + +## [14.1.0](https://github.com/appium/WebDriverAgent/compare/v14.0.0...v14.1.0) (2026-06-10) + +### Features + +* Add VoiceOver automation support ([#1153](https://github.com/appium/WebDriverAgent/issues/1153)) ([6b3631a](https://github.com/appium/WebDriverAgent/commit/6b3631aed7a95439b01a9a3bb87189df384dcf06)) + +## [14.0.0](https://github.com/appium/WebDriverAgent/compare/v13.3.0...v14.0.0) (2026-06-09) + +### ⚠ BREAKING CHANGES + +* bump the deployment target to 15 + +### Features + +* bump the deployment target to 15 ([#1152](https://github.com/appium/WebDriverAgent/issues/1152)) ([74498f7](https://github.com/appium/WebDriverAgent/commit/74498f79c9f00678f2bc37b9afb50f70e30d0f88)) + +## [13.3.0](https://github.com/appium/WebDriverAgent/compare/v13.2.4...v13.3.0) (2026-06-09) + +### Features + +* Expose native isAccessibilityElement ([#1146](https://github.com/appium/WebDriverAgent/issues/1146)) ([e615621](https://github.com/appium/WebDriverAgent/commit/e6156212e6fba6af98a69a400f5fa18b67f1e3e3)) + +## [13.2.4](https://github.com/appium/WebDriverAgent/compare/v13.2.3...v13.2.4) (2026-06-08) + +### Bug Fixes + +* update WebDriverAgentRunner app icon ([#1151](https://github.com/appium/WebDriverAgent/issues/1151)) ([eea2229](https://github.com/appium/WebDriverAgent/commit/eea2229f8d2e8bd2dd936fe3ddb69a9458789f49)) + +## [13.2.3](https://github.com/appium/WebDriverAgent/compare/v13.2.2...v13.2.3) (2026-06-07) + +### Bug Fixes + +* auto-handle iOS 18+ limited access permission prompt ([#1150](https://github.com/appium/WebDriverAgent/issues/1150)) ([98d79e7](https://github.com/appium/WebDriverAgent/commit/98d79e7c3875424cb4b5fdee55bb079286a14b05)) + +## [13.2.2](https://github.com/appium/WebDriverAgent/compare/v13.2.1...v13.2.2) (2026-06-06) + +### Miscellaneous Chores + +* Refactor session creation handler ([#1149](https://github.com/appium/WebDriverAgent/issues/1149)) ([923b523](https://github.com/appium/WebDriverAgent/commit/923b523b55f880b921de2c95a82786ce0699cb9d)) + +## [13.2.1](https://github.com/appium/WebDriverAgent/compare/v13.2.0...v13.2.1) (2026-06-06) + +### Miscellaneous Chores + +* Refactor settings handling API ([#1148](https://github.com/appium/WebDriverAgent/issues/1148)) ([ff7ac36](https://github.com/appium/WebDriverAgent/commit/ff7ac368debb22659509169a0eca530bae3dc879)) + +## [13.2.0](https://github.com/appium/WebDriverAgent/compare/v13.1.3...v13.2.0) (2026-05-26) + +### Features + +* Add XPath extensions ([#1144](https://github.com/appium/WebDriverAgent/issues/1144)) ([a975b89](https://github.com/appium/WebDriverAgent/commit/a975b89ac998d31a72bf3723b843d85af8867cf0)) + +## [13.1.3](https://github.com/appium/WebDriverAgent/compare/v13.1.2...v13.1.3) (2026-05-24) + +### Bug Fixes + +* Scheme for derived data path retrieval ([#1142](https://github.com/appium/WebDriverAgent/issues/1142)) ([9ada5f6](https://github.com/appium/WebDriverAgent/commit/9ada5f6fe2af29278c488e845f8714f22fabfeee)) + +## [13.1.2](https://github.com/appium/WebDriverAgent/compare/v13.1.1...v13.1.2) (2026-05-23) + +### Bug Fixes + +* Address compilation warnings ([#1143](https://github.com/appium/WebDriverAgent/issues/1143)) ([f1f9976](https://github.com/appium/WebDriverAgent/commit/f1f9976f4a0a0fb8a8aa3ee1f2483b25275600e6)) + +## [13.1.1](https://github.com/appium/WebDriverAgent/compare/v13.1.0...v13.1.1) (2026-05-22) + +### Bug Fixes + +* ship Scripts/embed-runner-icon.sh in the npm package ([#1141](https://github.com/appium/WebDriverAgent/issues/1141)) ([17ac1c1](https://github.com/appium/WebDriverAgent/commit/17ac1c16a0890ee0fbfe73504a3ff570dfe1a7bf)), closes [#1138](https://github.com/appium/WebDriverAgent/issues/1138) + +## [13.1.0](https://github.com/appium/WebDriverAgent/compare/v13.0.0...v13.1.0) (2026-05-21) + +### Features + +* add app icon to WebDriverAgentRunner ([#1138](https://github.com/appium/WebDriverAgent/issues/1138)) ([fe8adc8](https://github.com/appium/WebDriverAgent/commit/fe8adc89923994428783397170de850e11ebb3c6)) +* Add helper method to fetch build settings ([#1139](https://github.com/appium/WebDriverAgent/issues/1139)) ([56b5f38](https://github.com/appium/WebDriverAgent/commit/56b5f384ed9ba1a014d4b642ddf26b8573ceaafe)) + +## [13.0.0](https://github.com/appium/WebDriverAgent/compare/v12.2.2...v13.0.0) (2026-05-17) + +### ⚠ BREAKING CHANGES + +* quitAndUninstall() removed — use quit() only. App uninstall is out of scope for this module. +* uninstall() removed — WDA must not be uninstalled from this package; callers (e.g. xcuitest-driver) should own that if needed. +* setupCaching() no longer uninstalls WDA — on bundle-id or version mismatch it logs and skips caching instead of removing apps from the device. Also, it now returns the cached url on success. +* appium-ios-device dependency removed — preinstalled WDA on real devices always launches via devicectl (no iOS < 17 Xctest fallback). + +### Features + +* Drop legacy APIs ([#1137](https://github.com/appium/WebDriverAgent/issues/1137)) ([8995d24](https://github.com/appium/WebDriverAgent/commit/8995d24e16634a4624918319996839993502c7b4)) + +## [12.2.2](https://github.com/appium/WebDriverAgent/compare/v12.2.1...v12.2.2) (2026-05-08) + +### Bug Fixes + +* linter ([#1134](https://github.com/appium/WebDriverAgent/issues/1134)) ([2bd1816](https://github.com/appium/WebDriverAgent/commit/2bd181628a1d4525a8f1c459ea295ac0541b514c)) + +## [12.2.1](https://github.com/appium/WebDriverAgent/compare/v12.2.0...v12.2.1) (2026-05-06) + +### Miscellaneous Chores + +* **deps-dev:** bump sinon from 21.1.2 to 22.0.0 ([#1133](https://github.com/appium/WebDriverAgent/issues/1133)) ([11c579b](https://github.com/appium/WebDriverAgent/commit/11c579b7ed3a9995715d65590a2959763871aa6d)) + +## [12.2.0](https://github.com/appium/WebDriverAgent/compare/v12.1.1...v12.2.0) (2026-04-29) + +### Features + +* Ditch bluebird and lodash ([#1130](https://github.com/appium/WebDriverAgent/issues/1130)) ([8899895](https://github.com/appium/WebDriverAgent/commit/88998951f004daed1d22ce2c06eec89a08129e4f)) + +## [12.1.1](https://github.com/appium/WebDriverAgent/compare/v12.1.0...v12.1.1) (2026-04-27) + +### Miscellaneous Chores + +* **compile:** fix compilation ([#1129](https://github.com/appium/WebDriverAgent/issues/1129)) ([76d59e8](https://github.com/appium/WebDriverAgent/commit/76d59e85c75680c97abe9e67fdf4a70cacd46418)) + +## [12.1.0](https://github.com/appium/WebDriverAgent/compare/v12.0.0...v12.1.0) (2026-04-24) + +### Features + +* **client:** add ability to set headers on requests ([#1127](https://github.com/appium/WebDriverAgent/issues/1127)) ([a8889cd](https://github.com/appium/WebDriverAgent/commit/a8889cd7cb85c1b58faee306295fb3c5c2a9d0e3)) + +## [12.0.0](https://github.com/appium/WebDriverAgent/compare/v11.4.3...v12.0.0) (2026-04-14) + +### ⚠ BREAKING CHANGES + +* remove idb from AppleDevice; clients must stop passing device.idb. +* remove includeNonModalElements WDA setting; clients must stop sending this setting in /settings. +* remove shouldUseTestManagerForVisibilityDetection capability; clients must stop sending this desired capability. + +### Code Refactoring + +* remove deprecated WDA settings/capabilities and idb typing ([#1124](https://github.com/appium/WebDriverAgent/issues/1124)) ([5072e25](https://github.com/appium/WebDriverAgent/commit/5072e255faa3538f5ff4c8769bf16fd290ee8af9)) + +## [11.4.3](https://github.com/appium/WebDriverAgent/compare/v11.4.2...v11.4.3) (2026-04-12) + +### Miscellaneous Chores + +* **deps-dev:** bump typescript from 5.9.3 to 6.0.2 ([#1121](https://github.com/appium/WebDriverAgent/issues/1121)) ([046b080](https://github.com/appium/WebDriverAgent/commit/046b08042df33f507466f55b68b444c91684931a)) + +## [11.4.2](https://github.com/appium/WebDriverAgent/compare/v11.4.1...v11.4.2) (2026-04-12) + +### Bug Fixes + +* Avoid keeping strong reference to self instance in delegates ([#1123](https://github.com/appium/WebDriverAgent/issues/1123)) ([dd15f48](https://github.com/appium/WebDriverAgent/commit/dd15f48d33edabef6aea8ac951cf539946b492f2)) + ## [11.4.1](https://github.com/appium/WebDriverAgent/compare/v11.4.0...v11.4.1) (2026-03-15) ### Bug Fixes diff --git a/Gemfile b/Gemfile index ed0a22fa4..355ae9339 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,4 @@ source "https://rubygems.org" gem "fastlane", '~> 2.229' +gem "multi_json" diff --git a/PrivateHeaders/XCTest/CDStructures.h b/PrivateHeaders/XCTest/CDStructures.h index eaf3f46fa..56078e9aa 100644 --- a/PrivateHeaders/XCTest/CDStructures.h +++ b/PrivateHeaders/XCTest/CDStructures.h @@ -24,5 +24,8 @@ typedef struct { unsigned short _field3[1]; } CDStruct_27a325c0; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wreserved-identifier" int _XCTSetApplicationStateTimeout(double timeout); double _XCTApplicationStateTimeout(void); +#pragma clang diagnostic pop diff --git a/PrivateHeaders/XCTest/XCTestCase.h b/PrivateHeaders/XCTest/XCTestCase.h index d68d949c5..8e4a52161 100644 --- a/PrivateHeaders/XCTest/XCTestCase.h +++ b/PrivateHeaders/XCTest/XCTestCase.h @@ -8,7 +8,10 @@ #import +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wreserved-identifier" @class NSInvocation, XCTestCaseRun, XCTestContext, _XCTestCaseImplementation; +#pragma clang diagnostic pop @interface XCTestCase() { diff --git a/Scripts/build-webdriveragent.mjs b/Scripts/build-webdriveragent.mjs index 7fd864385..cd82cd622 100644 --- a/Scripts/build-webdriveragent.mjs +++ b/Scripts/build-webdriveragent.mjs @@ -20,6 +20,11 @@ const WDA_BUNDLE_TV_PATH = path.join(DERIVED_DATA_PATH, 'Build', 'Products', 'De const TARGETS = ['runner', 'tv_runner']; const SDKS = ['sim', 'tv_sim']; +/** + * Build WebDriverAgent and pack the app bundle into a zip archive. + * + * @param {string} [xcodeVersion] Xcode version to include in archive name. + */ async function buildWebDriverAgent (xcodeVersion) { const target = process.env.TARGET; const sdk = process.env.SDK; @@ -77,10 +82,12 @@ async function buildWebDriverAgent (xcodeVersion) { } if (isMainModule) { - buildWebDriverAgent().catch((e) => { + try { + await buildWebDriverAgent(); + } catch (e) { LOG.error(e); process.exit(1); - }); + } } export default buildWebDriverAgent; diff --git a/Scripts/embed-runner-icon.sh b/Scripts/embed-runner-icon.sh new file mode 100755 index 000000000..b71ba0ab5 --- /dev/null +++ b/Scripts/embed-runner-icon.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# Embed the WDA app icon into the wrapping XCTRunner host app so the +# installed WebDriverAgent shows the Appium logo on the iOS home screen +# instead of a blank icon. +# +# Apple's USES_XCTRUNNER auto-generates a Runner.app around UI-testing +# .xctest bundles but does not inherit icons from the test bundle's +# asset catalog. actool produces AppIcon*.png + Assets.car inside +# PlugIns/.xctest/ where iOS never looks. This script lifts +# them up to the Runner.app root and patches Info.plist accordingly. +# +# Limitations: +# - Touches XCTRunner internals; may need updates across Xcode versions. +# - iOS only; tvOS uses different "Brand Assets" and is not handled. +# - Cloud device farms that re-sign WDA must preserve these changes. + +set -euo pipefail + +RUNNER_APP="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}-Runner.app" +XCTEST="${RUNNER_APP}/PlugIns/${PRODUCT_NAME}.xctest" + +if [ ! -d "$RUNNER_APP" ]; then + echo "warning: ${PRODUCT_NAME}-Runner.app not found at $RUNNER_APP; skipping icon embed" + exit 0 +fi + +if [ ! -d "$XCTEST" ]; then + echo "warning: ${PRODUCT_NAME}.xctest not found inside Runner.app; skipping icon embed" + exit 0 +fi + +shopt -s nullglob +ICONS=("$XCTEST"/AppIcon*.png) +if [ ${#ICONS[@]} -eq 0 ]; then + echo "warning: no compiled AppIcon*.png found inside $XCTEST; skipping icon embed" + exit 0 +fi + +cp -f "${ICONS[@]}" "$RUNNER_APP/" +if [ -f "$XCTEST/Assets.car" ]; then + cp -f "$XCTEST/Assets.car" "$RUNNER_APP/" +fi + +PLIST="$RUNNER_APP/Info.plist" +/usr/libexec/PlistBuddy -c "Delete :CFBundleIcons" "$PLIST" 2>/dev/null || true +/usr/libexec/PlistBuddy -c "Delete :CFBundleIcons~ipad" "$PLIST" 2>/dev/null || true + +/usr/libexec/PlistBuddy -c "Add :CFBundleIcons dict" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleIcons:CFBundlePrimaryIcon dict" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconName string AppIcon" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconFiles array" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconFiles:0 string AppIcon60x60" "$PLIST" + +/usr/libexec/PlistBuddy -c "Add :CFBundleIcons~ipad dict" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleIcons~ipad:CFBundlePrimaryIcon dict" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconName string AppIcon" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconFiles array" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconFiles:0 string AppIcon60x60" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconFiles:1 string AppIcon76x76" "$PLIST" + +# Re-codesign since we modified the bundle after Xcode signed it. +# In a scheme post-action context Xcode's CODE_SIGN_* env vars are not exposed, +# so discover the existing signing identity from the already-signed bundle. +if [ -d "$RUNNER_APP/_CodeSignature" ]; then + # Capture the signature info once. Piping codesign straight into + # `awk ... exit` makes awk close the pipe early, killing codesign with + # SIGPIPE -- which `set -o pipefail` turns into a fatal error. That trips + # only when an Authority line exists, i.e. on every real-device build. + SIGN_INFO=$(codesign -dvv "$RUNNER_APP" 2>&1 || true) + EXISTING_IDENT="${EXPANDED_CODE_SIGN_IDENTITY:-}" + if [ -z "$EXISTING_IDENT" ]; then + EXISTING_IDENT=$(awk -F'=' '/^Authority/ {print $2; exit}' <<< "$SIGN_INFO") + fi + # Simulator builds are ad-hoc signed: there is no Authority line, but the + # bundle can still be re-signed ad-hoc with an identity of "-". + if [ -z "$EXISTING_IDENT" ] && grep -q '^Signature=adhoc' <<< "$SIGN_INFO"; then + EXISTING_IDENT="-" + fi + if [ -n "$EXISTING_IDENT" ]; then + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=identifier,entitlements "$RUNNER_APP" + else + echo "warning: bundle is signed but no identity discovered; signature will be invalid" + fi +fi + +echo "embedded icon into $RUNNER_APP" diff --git a/Scripts/fetch-prebuilt-wda.mjs b/Scripts/fetch-prebuilt-wda.mjs index 0fa1df409..2171e85c7 100644 --- a/Scripts/fetch-prebuilt-wda.mjs +++ b/Scripts/fetch-prebuilt-wda.mjs @@ -3,8 +3,6 @@ import { fileURLToPath } from 'node:url'; import { readFileSync } from 'node:fs'; import axios from 'axios'; import { logger, fs, mkdirp, net } from '@appium/support'; -import _ from 'lodash'; -import B from 'bluebird'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -12,6 +10,9 @@ const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === __file const log = logger.getLogger('WDA'); +/** + * Download all prebuilt WebDriverAgent archives for the current package version. + */ async function fetchPrebuiltWebDriverAgentAssets () { const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8')); const tag = packageJson.version; @@ -28,7 +29,7 @@ async function fetchPrebuiltWebDriverAgentAssets () { }, })).data; } catch (e) { - throw new Error(`Could not fetch endpoint ${downloadUrl}. Reason: ${e.message}`); + throw new Error(`Could not fetch endpoint ${downloadUrl}. Reason: ${e.message}`, {cause: e}); } const webdriveragentsDir = path.resolve(__dirname, '..', 'prebuilt-agents'); @@ -41,7 +42,9 @@ async function fetchPrebuiltWebDriverAgentAssets () { try { await net.downloadFile(url, targetPath); } catch (err) { - throw new Error(`Problem downloading webdriveragent from url ${url}: ${err.message}`); + throw new Error(`Problem downloading webdriveragent from url ${url}: ${err.message}`, { + cause: err, + }); } } @@ -51,20 +54,25 @@ async function fetchPrebuiltWebDriverAgentAssets () { const url = asset.browser_download_url; log.info(`Downloading: ${url}`); try { - const nameOfAgent = _.last(url.split('/')); + const nameOfAgent = url.split('/').at(-1); + if (!nameOfAgent) { + continue; + } agentsDownloading.push(downloadAgent(url, path.join(webdriveragentsDir, nameOfAgent))); } catch { } } // Wait for them all to finish - return await B.all(agentsDownloading); + return await Promise.all(agentsDownloading); } if (isMainModule) { - fetchPrebuiltWebDriverAgentAssets().catch((e) => { + try { + await fetchPrebuiltWebDriverAgentAssets(); + } catch (e) { log.error(e); process.exit(1); - }); + } } export default fetchPrebuiltWebDriverAgentAssets; diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index 2d353269e..3c0a06d69 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -467,6 +467,8 @@ 71B155DC230711E900646AFB /* FBCommandStatus.m in Sources */ = {isa = PBXBuildFile; fileRef = 71B155DB230711E900646AFB /* FBCommandStatus.m */; }; 71B155DF23080CA600646AFB /* FBProtocolHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 71B155DD23080CA600646AFB /* FBProtocolHelpers.h */; }; 71B155E123080CA600646AFB /* FBProtocolHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 71B155DE23080CA600646AFB /* FBProtocolHelpers.m */; }; + 71B2E0042733FB970074B004 /* FBXPathExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = 71B2E0022733FB970074B002 /* FBXPathExtensions.m */; }; + 71B2E0062733FB970074B006 /* FBXPathExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = 71B2E0022733FB970074B002 /* FBXPathExtensions.m */; }; 71B49EC71ED1A58100D51AD6 /* XCUIElement+FBUID.h in Headers */ = {isa = PBXBuildFile; fileRef = 71B49EC51ED1A58100D51AD6 /* XCUIElement+FBUID.h */; }; 71B49EC81ED1A58100D51AD6 /* XCUIElement+FBUID.m in Sources */ = {isa = PBXBuildFile; fileRef = 71B49EC61ED1A58100D51AD6 /* XCUIElement+FBUID.m */; }; 71BB58DE2B9631B700CB9BFE /* FBVideoRecordingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 71BB58DD2B9631B700CB9BFE /* FBVideoRecordingTests.m */; }; @@ -520,6 +522,10 @@ 71F3E7D525417FF400E0C22B /* FBSettings.h in Headers */ = {isa = PBXBuildFile; fileRef = 71F3E7D225417FF400E0C22B /* FBSettings.h */; }; 71F3E7D625417FF400E0C22B /* FBSettings.m in Sources */ = {isa = PBXBuildFile; fileRef = 71F3E7D325417FF400E0C22B /* FBSettings.m */; }; 71F3E7D725417FF400E0C22B /* FBSettings.m in Sources */ = {isa = PBXBuildFile; fileRef = 71F3E7D325417FF400E0C22B /* FBSettings.m */; }; + 71F3E7D825417FF400E0C22C /* FBSettingsHandler.h in Headers */ = {isa = PBXBuildFile; fileRef = 71F3E7D625417FF400E0C22C /* FBSettingsHandler.h */; }; + 71F3E7D925417FF400E0C22C /* FBSettingsHandler.h in Headers */ = {isa = PBXBuildFile; fileRef = 71F3E7D625417FF400E0C22C /* FBSettingsHandler.h */; }; + 71F3E7DA25417FF400E0C22C /* FBSettingsHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 71F3E7D725417FF400E0C22C /* FBSettingsHandler.m */; }; + 71F3E7DB25417FF400E0C22C /* FBSettingsHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 71F3E7D725417FF400E0C22C /* FBSettingsHandler.m */; }; 71F5BE23252E576C00EE9EBA /* XCUIElement+FBSwiping.h in Headers */ = {isa = PBXBuildFile; fileRef = 71F5BE21252E576C00EE9EBA /* XCUIElement+FBSwiping.h */; }; 71F5BE24252E576C00EE9EBA /* XCUIElement+FBSwiping.h in Headers */ = {isa = PBXBuildFile; fileRef = 71F5BE21252E576C00EE9EBA /* XCUIElement+FBSwiping.h */; }; 71F5BE25252E576C00EE9EBA /* XCUIElement+FBSwiping.m in Sources */ = {isa = PBXBuildFile; fileRef = 71F5BE22252E576C00EE9EBA /* XCUIElement+FBSwiping.m */; }; @@ -529,6 +535,12 @@ 71F5BE50252F14EB00EE9EBA /* FBExceptions.h in Headers */ = {isa = PBXBuildFile; fileRef = 71F5BE4D252F14EB00EE9EBA /* FBExceptions.h */; }; 71F5BE51252F14EB00EE9EBA /* FBExceptions.m in Sources */ = {isa = PBXBuildFile; fileRef = 71F5BE4E252F14EB00EE9EBA /* FBExceptions.m */; }; 71F5BE52252F14EB00EE9EBA /* FBExceptions.m in Sources */ = {isa = PBXBuildFile; fileRef = 71F5BE4E252F14EB00EE9EBA /* FBExceptions.m */; }; + A1B2C3D41F001A00A1B0004 /* XCUIDevice+FBVoiceOver.h in Headers */ = {isa = PBXBuildFile; fileRef = A1B2C3D41F001A00A1B0001 /* XCUIDevice+FBVoiceOver.h */; settings = {ATTRIBUTES = (Public, ); }; }; + A1B2C3D41F001A00A1B0005 /* XCUIDevice+FBVoiceOver.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41F001A00A1B0002 /* XCUIDevice+FBVoiceOver.m */; }; + A1B2C3D41F001A00A1B0006 /* XCUIDevice+FBVoiceOver.h in Headers */ = {isa = PBXBuildFile; fileRef = A1B2C3D41F001A00A1B0001 /* XCUIDevice+FBVoiceOver.h */; settings = {ATTRIBUTES = (Public, ); }; }; + A1B2C3D41F001A00A1B0007 /* XCUIDevice+FBVoiceOver.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41F001A00A1B0002 /* XCUIDevice+FBVoiceOver.m */; }; + A1B2C3D41F001A00A1B0008 /* FBVoiceOverTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41F001A00A1B0003 /* FBVoiceOverTests.m */; }; + A1B2C3D4E5F600000000001B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600000000001A /* Assets.xcassets */; }; AABBCCDDEEFF001122334457 /* SceneDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = AABBCCDDEEFF001122334456 /* SceneDelegate.m */; }; AD35D06C1CF1C35500870A75 /* WebDriverAgentLib.framework in Copy frameworks */ = {isa = PBXBuildFile; fileRef = EE158A991CBD452B00A3E3F0 /* WebDriverAgentLib.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; AD6C26941CF2379700F8B5FF /* FBAlert.h in Headers */ = {isa = PBXBuildFile; fileRef = AD6C26921CF2379700F8B5FF /* FBAlert.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -1081,6 +1093,8 @@ 71B155DB230711E900646AFB /* FBCommandStatus.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBCommandStatus.m; sourceTree = ""; }; 71B155DD23080CA600646AFB /* FBProtocolHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBProtocolHelpers.h; sourceTree = ""; }; 71B155DE23080CA600646AFB /* FBProtocolHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBProtocolHelpers.m; sourceTree = ""; }; + 71B2E0012733FB970074B001 /* FBXPathExtensions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBXPathExtensions.h; sourceTree = ""; }; + 71B2E0022733FB970074B002 /* FBXPathExtensions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBXPathExtensions.m; sourceTree = ""; }; 71B49EC51ED1A58100D51AD6 /* XCUIElement+FBUID.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "XCUIElement+FBUID.h"; sourceTree = ""; }; 71B49EC61ED1A58100D51AD6 /* XCUIElement+FBUID.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "XCUIElement+FBUID.m"; sourceTree = ""; }; 71BB58DD2B9631B700CB9BFE /* FBVideoRecordingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBVideoRecordingTests.m; sourceTree = ""; }; @@ -1109,11 +1123,17 @@ 71E75E6C254824230099FC87 /* XCUIElementQuery+FBHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "XCUIElementQuery+FBHelpers.m"; sourceTree = ""; }; 71F3E7D225417FF400E0C22B /* FBSettings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBSettings.h; sourceTree = ""; }; 71F3E7D325417FF400E0C22B /* FBSettings.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBSettings.m; sourceTree = ""; }; + 71F3E7D625417FF400E0C22C /* FBSettingsHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBSettingsHandler.h; sourceTree = ""; }; + 71F3E7D725417FF400E0C22C /* FBSettingsHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBSettingsHandler.m; sourceTree = ""; }; 71F5BE21252E576C00EE9EBA /* XCUIElement+FBSwiping.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "XCUIElement+FBSwiping.h"; sourceTree = ""; }; 71F5BE22252E576C00EE9EBA /* XCUIElement+FBSwiping.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "XCUIElement+FBSwiping.m"; sourceTree = ""; }; 71F5BE33252E5B2200EE9EBA /* FBElementSwipingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBElementSwipingTests.m; sourceTree = ""; }; 71F5BE4D252F14EB00EE9EBA /* FBExceptions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBExceptions.h; sourceTree = ""; }; 71F5BE4E252F14EB00EE9EBA /* FBExceptions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBExceptions.m; sourceTree = ""; }; + A1B2C3D41F001A00A1B0001 /* XCUIDevice+FBVoiceOver.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "XCUIDevice+FBVoiceOver.h"; sourceTree = ""; }; + A1B2C3D41F001A00A1B0002 /* XCUIDevice+FBVoiceOver.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "XCUIDevice+FBVoiceOver.m"; sourceTree = ""; }; + A1B2C3D41F001A00A1B0003 /* FBVoiceOverTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVoiceOverTests.m; sourceTree = ""; }; + A1B2C3D4E5F600000000001A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = WebDriverAgentRunner/Assets.xcassets; sourceTree = SOURCE_ROOT; }; AABBCCDDEEFF001122334455 /* SceneDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SceneDelegate.h; sourceTree = ""; }; AABBCCDDEEFF001122334456 /* SceneDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SceneDelegate.m; sourceTree = ""; }; AD42DD2A1CF121E600806E5D /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; @@ -1786,6 +1806,8 @@ EEDFE1201D9C06F800E6FFE5 /* XCUIDevice+FBHealthCheck.m */, AD6C26961CF2481700F8B5FF /* XCUIDevice+FBHelpers.h */, AD6C26971CF2481700F8B5FF /* XCUIDevice+FBHelpers.m */, + A1B2C3D41F001A00A1B0001 /* XCUIDevice+FBVoiceOver.h */, + A1B2C3D41F001A00A1B0002 /* XCUIDevice+FBVoiceOver.m */, EEE3763D1D59F81400ED88DD /* XCUIDevice+FBRotation.h */, EEE3763E1D59F81400ED88DD /* XCUIDevice+FBRotation.m */, EE9AB7451CAEDF0C008C271F /* XCUIElement+FBAccessibility.h */, @@ -1976,6 +1998,8 @@ EE9AB7921CAEDF0C008C271F /* FBRuntimeUtils.m */, 71F3E7D225417FF400E0C22B /* FBSettings.h */, 71F3E7D325417FF400E0C22B /* FBSettings.m */, + 71F3E7D625417FF400E0C22C /* FBSettingsHandler.h */, + 71F3E7D725417FF400E0C22C /* FBSettingsHandler.m */, 715AFABF1FFA29180053896D /* FBScreen.h */, 715AFAC01FFA29180053896D /* FBScreen.m */, 71C9EAAA25E8415A00470CD8 /* FBScreenshot.h */, @@ -2000,6 +2024,8 @@ 712A0C861DA3E55D007D02E5 /* FBXPath-Private.h */, 711084421DA3AA7500F913D6 /* FBXPath.h */, 711084431DA3AA7500F913D6 /* FBXPath.m */, + 71B2E0012733FB970074B001 /* FBXPathExtensions.h */, + 71B2E0022733FB970074B002 /* FBXPathExtensions.m */, EE6B64FB1D0F86EF00E85F5D /* XCTestPrivateSymbols.h */, EE6B64FC1D0F86EF00E85F5D /* XCTestPrivateSymbols.m */, 633E904A220DEE7F007CADF9 /* XCUIApplicationProcessDelay.h */, @@ -2054,6 +2080,7 @@ AD76723F1D6B826F00610457 /* FBTypingTest.m */, 714CA3C61DC23186000F12C9 /* FBXPathIntegrationTests.m */, 71BB58DD2B9631B700CB9BFE /* FBVideoRecordingTests.m */, + A1B2C3D41F001A00A1B0003 /* FBVoiceOverTests.m */, 71241D7D1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m */, 71241D7F1FAF087500B9559F /* FBW3CMultiTouchActionsIntegrationTests.m */, 7136C0F8243A182400921C76 /* FBW3CTypeActionsTests.m */, @@ -2294,6 +2321,7 @@ children = ( EE9AB7FC1CAEE048008C271F /* Info.plist */, EE9AB7FD1CAEE048008C271F /* UITestingUITests.m */, + A1B2C3D4E5F600000000001A /* Assets.xcassets */, ); name = WebDriverAgentRunner; path = XCTUITestRunner; @@ -2324,6 +2352,7 @@ 13DE7A50287C46BB003243C6 /* FBXCElementSnapshot.h in Headers */, 13DE7A56287CA1EC003243C6 /* FBXCElementSnapshotWrapper.h in Headers */, 71F3E7D525417FF400E0C22B /* FBSettings.h in Headers */, + 71F3E7D825417FF400E0C22C /* FBSettingsHandler.h in Headers */, 641EE63A2240C5CA00173FCB /* XCTest.h in Headers */, 641EE63B2240C5CA00173FCB /* FBAlertsMonitor.h in Headers */, 641EE63D2240C5CA00173FCB /* FBSession.h in Headers */, @@ -2495,6 +2524,7 @@ 71822777258744CE00661B83 /* DDNumber.h in Headers */, 641EE6C82240C5CA00173FCB /* XCTKVOExpectation.h in Headers */, 641EE6C92240C5CA00173FCB /* XCUIDevice+FBRotation.h in Headers */, + A1B2C3D41F001A00A1B0004 /* XCUIDevice+FBVoiceOver.h in Headers */, 641EE6CA2240C5CA00173FCB /* XCEventGenerator.h in Headers */, 719DCF162601EAFB000E765F /* FBNotificationsHelper.h in Headers */, 71414ED52670A1EE003A8C5D /* LRUCache.h in Headers */, @@ -2737,10 +2767,12 @@ 719DCF152601EAFB000E765F /* FBNotificationsHelper.h in Headers */, EE35AD4A1E3B77D600A02D78 /* XCTestExpectationDelegate-Protocol.h in Headers */, 71F3E7D425417FF400E0C22B /* FBSettings.h in Headers */, + 71F3E7D925417FF400E0C22C /* FBSettingsHandler.h in Headers */, EE35AD641E3B77D600A02D78 /* XCTUIApplicationMonitor-Protocol.h in Headers */, EE35AD591E3B77D600A02D78 /* XCTKVOExpectation.h in Headers */, 13DE7A43287C2A8D003243C6 /* FBXCAccessibilityElement.h in Headers */, EEE376431D59F81400ED88DD /* XCUIDevice+FBRotation.h in Headers */, + A1B2C3D41F001A00A1B0006 /* XCUIDevice+FBVoiceOver.h in Headers */, EE35AD2E1E3B77D600A02D78 /* XCEventGenerator.h in Headers */, EE9B76A61CF7A43900275851 /* FBConfiguration.h in Headers */, EE35AD571E3B77D600A02D78 /* XCTestSuiteRun.h in Headers */, @@ -3110,6 +3142,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + A1B2C3D4E5F600000000001B /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3149,9 +3182,11 @@ E444DCD624917A5E0060D7EB /* DDRange.m in Sources */, 641EE5D72240C5CA00173FCB /* FBScreenshotCommands.m in Sources */, 71F3E7D725417FF400E0C22B /* FBSettings.m in Sources */, + 71F3E7DA25417FF400E0C22C /* FBSettingsHandler.m in Sources */, 641EE5D92240C5CA00173FCB /* XCUIElement+FBPickerWheel.m in Sources */, 641EE5DA2240C5CA00173FCB /* XCUIApplicationProcessDelay.m in Sources */, 641EE5DB2240C5CA00173FCB /* FBXPath.m in Sources */, + 71B2E0042733FB970074B004 /* FBXPathExtensions.m in Sources */, 71C8E55425399A6B008572C1 /* XCUIApplication+FBQuiescence.m in Sources */, 641EE5DC2240C5CA00173FCB /* XCUIApplication+FBAlert.m in Sources */, 641EE70F2240CE4800173FCB /* FBTVNavigationTracker.m in Sources */, @@ -3184,6 +3219,7 @@ 641EE5F22240C5CA00173FCB /* NSPredicate+FBFormat.m in Sources */, 718F49CA23087AD30045FE8B /* FBProtocolHelpers.m in Sources */, 641EE5F42240C5CA00173FCB /* XCUIDevice+FBRotation.m in Sources */, + A1B2C3D41F001A00A1B0005 /* XCUIDevice+FBVoiceOver.m in Sources */, 13815F722328D20400CDAB61 /* FBActiveAppDetectionPoint.m in Sources */, 71D475C52538F5A8008D9401 /* XCUIApplicationProcess+FBQuiescence.m in Sources */, 641EE5F52240C5CA00173FCB /* XCUIElement+FBUID.m in Sources */, @@ -3273,6 +3309,7 @@ 6385F4A7220A40760095BBDB /* XCUIApplicationProcessDelay.m in Sources */, 71A5C67529A4F39600421C37 /* XCTIssue+FBPatcher.m in Sources */, 711084451DA3AA7500F913D6 /* FBXPath.m in Sources */, + 71B2E0062733FB970074B006 /* FBXPathExtensions.m in Sources */, 719CD8FD2126C88B00C7D0C2 /* XCUIApplication+FBAlert.m in Sources */, 13DE7A45287C2A8D003243C6 /* FBXCAccessibilityElement.m in Sources */, 641EE70E2240CE4800173FCB /* FBTVNavigationTracker.m in Sources */, @@ -3310,6 +3347,7 @@ 71A224E61DE2F56600844D55 /* NSPredicate+FBFormat.m in Sources */, E444DC85249131B10060D7EB /* DDNumber.m in Sources */, EEE376441D59F81400ED88DD /* XCUIDevice+FBRotation.m in Sources */, + A1B2C3D41F001A00A1B0007 /* XCUIDevice+FBVoiceOver.m in Sources */, 13815F712328D20400CDAB61 /* FBActiveAppDetectionPoint.m in Sources */, 71B49EC81ED1A58100D51AD6 /* XCUIElement+FBUID.m in Sources */, EE158AE21CBD456F00A3E3F0 /* FBRouteRequest.m in Sources */, @@ -3362,6 +3400,7 @@ EE158AB91CBD456F00A3E3F0 /* FBAlertViewCommands.m in Sources */, 71BB58F12B96511800CB9BFE /* FBVideoCommands.m in Sources */, 71F3E7D625417FF400E0C22B /* FBSettings.m in Sources */, + 71F3E7DB25417FF400E0C22C /* FBSettingsHandler.m in Sources */, 13DE7A57287CA1EC003243C6 /* FBXCElementSnapshotWrapper.m in Sources */, 71BB58F82B96531900CB9BFE /* FBScreenRecordingContainer.m in Sources */, EE158AB31CBD456F00A3E3F0 /* XCUIElement+FBScrolling.m in Sources */, @@ -3398,6 +3437,7 @@ files = ( 71241D801FAF087500B9559F /* FBW3CMultiTouchActionsIntegrationTests.m in Sources */, 71BB58DE2B9631B700CB9BFE /* FBVideoRecordingTests.m in Sources */, + A1B2C3D41F001A00A1B0008 /* FBVoiceOverTests.m in Sources */, 71241D7E1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m in Sources */, 63FD950221F9D06100A3E356 /* FBImageProcessorTests.m in Sources */, 719CD8FF2126C90200C7D0C2 /* FBAutoAlertsHandlerTests.m in Sources */, @@ -3952,13 +3992,13 @@ "$(SDKROOT)/usr/include/libxml2", "$(SRCROOT)/Modules", ); - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; TARGETED_DEVICE_FAMILY = "1,2"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; VALIDATE_WORKSPACE = NO; }; name = Debug; @@ -4015,12 +4055,12 @@ "$(SDKROOT)/usr/include/libxml2", "$(SRCROOT)/Modules", ); - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; TARGETED_DEVICE_FAMILY = "1,2"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; VALIDATE_PRODUCT = YES; VALIDATE_WORKSPACE = NO; }; @@ -4276,7 +4316,7 @@ CLANG_ANALYZER_NONNULL = YES; DEBUG_INFORMATION_FORMAT = dwarf; INFOPLIST_FILE = WebDriverAgentTests/IntegrationApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4293,7 +4333,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ANALYZER_NONNULL = YES; INFOPLIST_FILE = WebDriverAgentTests/IntegrationApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4346,6 +4386,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = EEE5CABF1C80361500CBBDD9 /* IOSSettings.xcconfig */; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_STATIC_ANALYZER_MODE = deep; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_TESTING_SEARCH_PATHS = YES; @@ -4399,6 +4440,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = EEE5CABF1C80361500CBBDD9 /* IOSSettings.xcconfig */; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_STATIC_ANALYZER_MODE = deep; ENABLE_TESTING_SEARCH_PATHS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner.xcscheme index da77fd577..157829787 100644 --- a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner.xcscheme +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + +NS_ASSUME_NONNULL_BEGIN + @interface XCUIApplication (FBAlert) /* The accessiblity label used for Safari app */ @@ -18,6 +20,16 @@ extern NSString *const FB_SAFARI_APP_NAME; @return Alert element instance */ -- (XCUIElement *)fb_alertElement; +- (nullable XCUIElement *)fb_alertElement; + +/** + Retrieve an alert element hosted by the iOS 18+ limited access permission prompt + process. See https://github.com/appium/appium/issues/20591 + + @return Alert element instance if the prompt is present, otherwise nil + */ ++ (nullable XCUIElement *)fb_limitedAccessPromptAlertElement; @end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index 36628bc61..ba86f4bc1 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -17,9 +17,22 @@ NSString *const FB_SAFARI_APP_NAME = @"Safari"; +// The iOS 18+ limited access permission prompt (e.g. the "Select Contacts" view) +// runs in a dedicated process that is not reported by fb_activeApplications. +static NSString *const FB_LIMITED_ACCESS_PROMPT_BUNDLE_ID = @"com.apple.ContactsUI.LimitedAccessPromptView"; + @implementation XCUIApplication (FBAlert) ++ (nullable XCUIElement *)fb_limitedAccessPromptAlertElement +{ + XCUIApplication *promptApp = [[XCUIApplication alloc] initWithBundleIdentifier:FB_LIMITED_ACCESS_PROMPT_BUNDLE_ID]; + if (promptApp.state < XCUIApplicationStateRunningForeground) { + return nil; + } + return promptApp.fb_alertElement; +} + - (nullable XCUIElement *)fb_alertElementFromSafariWithScrollView:(XCUIElement *)scrollView viewSnapshot:(id)viewSnapshot { diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.m b/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.m index bda8a8c5f..454288831 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.m @@ -44,6 +44,7 @@ static NSString* const FBExclusionAttributeEnabled = @"enabled"; static NSString* const FBExclusionAttributeVisible = @"visible"; static NSString* const FBExclusionAttributeAccessible = @"accessible"; +static NSString* const FBExclusionAttributeNativeAccessibilityElement = @"nativeAccessibilityElement"; static NSString* const FBExclusionAttributeFocused = @"focused"; static NSString* const FBExclusionAttributePlaceholderValue = @"placeholderValue"; static NSString* const FBExclusionAttributeNativeFrame = @"nativeFrame"; @@ -51,6 +52,13 @@ static NSString* const FBExclusionAttributeMinValue = @"minValue"; static NSString* const FBExclusionAttributeMaxValue = @"maxValue"; +static NSString *FBJsonPrefixedAttributeKey(NSString *key) +{ + return [NSString stringWithFormat:@"is%@%@", + [[key substringToIndex:1] uppercaseString], + [key substringFromIndex:1]]; +} + _Nullable id extractIssueProperty(id issue, NSString *propertyName) { SEL selector = NSSelectorFromString(propertyName); NSMethodSignature *methodSignature = [issue methodSignatureForSelector:selector]; @@ -223,7 +231,7 @@ + (NSDictionary *)dictionaryForElement:(id)snapshot if ([nonPrefixedKeys containsObject:key]) { info[key] = value; } else { - info[[NSString stringWithFormat:@"is%@", [key capitalizedString]]] = value; + info[FBJsonPrefixedAttributeKey(key)] = value; } } } @@ -268,6 +276,9 @@ + (NSDictionary *)dictionaryForElement:(id)snapshot }, FBExclusionAttributeAccessible: ^{ return [@([wrappedSnapshot isWDAccessible]) stringValue]; + }, + FBExclusionAttributeNativeAccessibilityElement: ^{ + return [@([wrappedSnapshot isWDNativeAccessibilityElement]) stringValue]; }, FBExclusionAttributeFocused: ^{ return [@([wrappedSnapshot isWDFocused]) stringValue]; diff --git a/WebDriverAgentLib/Categories/XCUIDevice+FBHelpers.h b/WebDriverAgentLib/Categories/XCUIDevice+FBHelpers.h index db03b7a1f..cbe308543 100644 --- a/WebDriverAgentLib/Categories/XCUIDevice+FBHelpers.h +++ b/WebDriverAgentLib/Categories/XCUIDevice+FBHelpers.h @@ -169,7 +169,7 @@ typedef NS_ENUM(NSUInteger, FBUIInterfaceAppearance) { #if !TARGET_OS_TV /** Allows to set a simulated geolocation coordinates. - Only works since Xcode 14.3/iOS 16.4 + Only works since iOS 16.4 runtime @param location The simlated location coordinates to set @param error If there is an error, upon return contains an NSError object that describes the problem. @@ -179,7 +179,7 @@ typedef NS_ENUM(NSUInteger, FBUIInterfaceAppearance) { /** Allows to get a simulated geolocation coordinates. - Only works since Xcode 14.3/iOS 16.4 + Only works since iOS 16.4 runtime @param error If there is an error, upon return contains an NSError object that describes the problem. @return The current simulated location or nil in case of failure or if no location has previously been seet @@ -189,7 +189,7 @@ typedef NS_ENUM(NSUInteger, FBUIInterfaceAppearance) { /** Allows to clear a previosuly set simulated geolocation coordinates. - Only works since Xcode 14.3/iOS 16.4 + Only works since iOS 16.4 runtime @param error If there is an error, upon return contains an NSError object that describes the problem. @return YES if the simulated location has been successfully cleared diff --git a/WebDriverAgentLib/Categories/XCUIDevice+FBHelpers.m b/WebDriverAgentLib/Categories/XCUIDevice+FBHelpers.m index 7835a1403..0b5d0f0b1 100644 --- a/WebDriverAgentLib/Categories/XCUIDevice+FBHelpers.m +++ b/WebDriverAgentLib/Categories/XCUIDevice+FBHelpers.m @@ -229,10 +229,10 @@ - (BOOL)fb_openUrl:(NSString *)url error:(NSError **)error return [self fb_activateSiriVoiceRecognitionWithText:[NSString stringWithFormat:@"Open {%@}", url] error:error]; } - NSString *description = [NSString stringWithFormat:@"Cannot open '%@' with the default application assigned for it. Consider upgrading to Xcode 14.3+/iOS 16.4+", url]; + NSString *description = [NSString stringWithFormat:@"Cannot open '%@' with the default application assigned for it. This API requires an iOS 16.4+ runtime", url]; return [[[FBErrorBuilder builder] withDescriptionFormat:@"%@", description] - buildError:error];; + buildError:error]; } - (BOOL)fb_openUrl:(NSString *)url withApplication:(NSString *)bundleId error:(NSError **)error diff --git a/WebDriverAgentLib/Categories/XCUIDevice+FBRotation.m b/WebDriverAgentLib/Categories/XCUIDevice+FBRotation.m index 164b23e7b..269d77f6c 100644 --- a/WebDriverAgentLib/Categories/XCUIDevice+FBRotation.m +++ b/WebDriverAgentLib/Categories/XCUIDevice+FBRotation.m @@ -30,19 +30,38 @@ - (BOOL)fb_setDeviceRotation:(NSDictionary *)rotationObj if (keysForRotationObj.count == 0) { return NO; } - NSInteger orientation = keysForRotationObj.firstObject.integerValue; + UIDeviceOrientation orientation = (UIDeviceOrientation)keysForRotationObj.firstObject.integerValue; XCUIApplication *application = XCUIApplication.fb_activeApplication; [XCUIDevice sharedDevice].orientation = orientation; return [self waitUntilInterfaceIsAtOrientation:orientation application:application]; } -- (BOOL)waitUntilInterfaceIsAtOrientation:(NSInteger)orientation application:(XCUIApplication *)application +static UIInterfaceOrientation FBInterfaceOrientationFromDeviceOrientation(UIDeviceOrientation orientation) +{ + switch (orientation) { + case UIDeviceOrientationPortrait: + return UIInterfaceOrientationPortrait; + case UIDeviceOrientationPortraitUpsideDown: + return UIInterfaceOrientationPortraitUpsideDown; + case UIDeviceOrientationLandscapeLeft: + return UIInterfaceOrientationLandscapeRight; + case UIDeviceOrientationLandscapeRight: + return UIInterfaceOrientationLandscapeLeft; + case UIDeviceOrientationUnknown: + case UIDeviceOrientationFaceUp: + case UIDeviceOrientationFaceDown: + default: + return UIInterfaceOrientationUnknown; + } +} + +- (BOOL)waitUntilInterfaceIsAtOrientation:(UIDeviceOrientation)orientation application:(XCUIApplication *)application { // Tapping elements immediately after rotation may fail due to way UIKit is handling touches. // We should wait till UI cools off, before continuing [application fb_waitUntilStableWithTimeout:FBConfiguration.animationCoolOffTimeout]; - return application.interfaceOrientation == orientation; + return application.interfaceOrientation == FBInterfaceOrientationFromDeviceOrientation(orientation); } - (NSDictionary *)fb_rotationMapping diff --git a/WebDriverAgentLib/Categories/XCUIDevice+FBVoiceOver.h b/WebDriverAgentLib/Categories/XCUIDevice+FBVoiceOver.h new file mode 100644 index 000000000..91ad3e688 --- /dev/null +++ b/WebDriverAgentLib/Categories/XCUIDevice+FBVoiceOver.h @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface XCUIDevice (FBVoiceOver) + +/** + Whether VoiceOver control APIs are available in the current OS runtime. + + @return YES if the VoiceOver service is exposed by XCUIDevice + */ +- (BOOL)fb_isVoiceOverServiceAvailable; + +/** + Enable VoiceOver. Only works since iOS 27 runtime. + + @param error If there is an error, upon return contains an NSError object that describes the problem. + @return YES if VoiceOver has been successfully enabled + */ +- (BOOL)fb_enableVoiceOver:(NSError **)error; + +/** + Disable VoiceOver. Only works since iOS 27 runtime. + + @param error If there is an error, upon return contains an NSError object that describes the problem. + @return YES if VoiceOver has been successfully disabled + */ +- (BOOL)fb_disableVoiceOver:(NSError **)error; + +/** + Whether VoiceOver is currently enabled. Only works since iOS 27 runtime. + + @param error If there is an error, upon return contains an NSError object that describes the problem. + @return YES if VoiceOver is enabled + */ +- (BOOL)fb_isVoiceOverEnabled:(NSError **)error; + +/** + Move VoiceOver focus and return speech for the newly focused element. + Only works since iOS 27 runtime. + + @param direction One of: forward, backward, in (iOS only), out (iOS only) + @param error If there is an error, upon return contains an NSError object that describes the problem. + @return The spoken utterance or nil in case of failure + */ +- (nullable NSString *)fb_voiceOverMove:(NSString *)direction error:(NSError **)error; + +/** + Return the speech for the currently focused element. Only works since iOS 27 runtime. + + @param error If there is an error, upon return contains an NSError object that describes the problem. + @return The spoken utterance or nil in case of failure + */ +- (nullable NSString *)fb_voiceOverCurrentSpeech:(NSError **)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Categories/XCUIDevice+FBVoiceOver.m b/WebDriverAgentLib/Categories/XCUIDevice+FBVoiceOver.m new file mode 100644 index 000000000..6e054b1f7 --- /dev/null +++ b/WebDriverAgentLib/Categories/XCUIDevice+FBVoiceOver.m @@ -0,0 +1,216 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "XCUIDevice+FBVoiceOver.h" + +#import "FBErrorBuilder.h" + +static NSString *const FBVoiceOverSDKUnsupportedError = +@"The current OS runtime does not support VoiceOver control. This API requires an iOS 27+ runtime"; + +static BOOL FBVoiceOverBuildSDKUnsupportedError(NSError **error) +{ + return [[[FBErrorBuilder builder] + withDescription:FBVoiceOverSDKUnsupportedError] + buildError:error]; +} + +static BOOL FBIsVoiceOverServiceAvailable(void) +{ + static BOOL isAvailable = NO; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + isAvailable = [XCUIDevice.sharedDevice respondsToSelector:NSSelectorFromString(@"voiceOverService")]; + }); + return isAvailable; +} + +static id FBVoiceOverService(NSError **error) +{ + if (!FBIsVoiceOverServiceAvailable()) { + FBVoiceOverBuildSDKUnsupportedError(error); + return nil; + } + return [XCUIDevice.sharedDevice valueForKey:@"voiceOverService"]; +} + +static BOOL FBInvokeVoiceOverBoolMethod(id voiceOverService, + SEL selector, + NSError **error) +{ + if (![voiceOverService respondsToSelector:selector]) { + return FBVoiceOverBuildSDKUnsupportedError(error); + } + + NSMethodSignature *signature = [voiceOverService methodSignatureForSelector:selector]; + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; + [invocation setSelector:selector]; + [invocation setTarget:voiceOverService]; + NSError *invokeError = nil; + [invocation setArgument:&invokeError atIndex:2]; + [invocation invoke]; + if (nil != invokeError) { + if (error) { + *error = invokeError; + } + return NO; + } + + BOOL result = NO; + [invocation getReturnValue:&result]; + return result; +} + +static id FBInvokeVoiceOverOutputMethod(id voiceOverService, + SEL selector, + NSError **error) +{ + if (![voiceOverService respondsToSelector:selector]) { + FBVoiceOverBuildSDKUnsupportedError(error); + return nil; + } + + NSMethodSignature *signature = [voiceOverService methodSignatureForSelector:selector]; + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; + [invocation setSelector:selector]; + [invocation setTarget:voiceOverService]; + NSError *invokeError = nil; + [invocation setArgument:&invokeError atIndex:2]; + [invocation invoke]; + if (nil != invokeError) { + if (error) { + *error = invokeError; + } + return nil; + } + + id __unsafe_unretained output = nil; + [invocation getReturnValue:&output]; + return output; +} + +static NSString *FBUtteranceFromVoiceOverOutput(id output, NSError **error) +{ + if (nil == output) { + return nil; + } + + if (![output respondsToSelector:NSSelectorFromString(@"utterance")]) { + [[[FBErrorBuilder builder] + withDescription:@"VoiceOver output does not provide an utterance"] + buildError:error]; + return nil; + } + + id utterance = [output valueForKey:@"utterance"]; + return [utterance isKindOfClass:NSString.class] ? utterance : nil; +} + +static NSString *FBVoiceOverSpeechFromSelector(SEL selector, NSError **error) +{ + id service = FBVoiceOverService(error); + if (nil == service) { + return nil; + } + + id output = FBInvokeVoiceOverOutputMethod(service, selector, error); + if (nil != error && nil != *error) { + return nil; + } + return FBUtteranceFromVoiceOverOutput(output, error); +} + +static NSDictionary *FBVoiceOverMoveSelectors(void) +{ + static NSDictionary *selectors = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSMutableDictionary *mapping = [@{ + @"forward": @"moveForwardAndReturnError:", + @"backward": @"moveBackwardAndReturnError:", + } mutableCopy]; +#if TARGET_OS_IOS + mapping[@"in"] = @"moveInAndReturnError:"; + mapping[@"out"] = @"moveOutAndReturnError:"; +#endif + selectors = mapping.copy; + }); + return selectors; +} + +@implementation XCUIDevice (FBVoiceOver) + +- (BOOL)fb_isVoiceOverServiceAvailable +{ + return FBIsVoiceOverServiceAvailable(); +} + +- (BOOL)fb_enableVoiceOver:(NSError **)error +{ + id service = FBVoiceOverService(error); + if (nil == service) { + return NO; + } + return FBInvokeVoiceOverBoolMethod(service, + NSSelectorFromString(@"enableAndReturnError:"), + error); +} + +- (BOOL)fb_disableVoiceOver:(NSError **)error +{ + id service = FBVoiceOverService(error); + if (nil == service) { + return NO; + } + return FBInvokeVoiceOverBoolMethod(service, + NSSelectorFromString(@"disableAndReturnError:"), + error); +} + +- (BOOL)fb_isVoiceOverEnabled:(NSError **)error +{ + id service = FBVoiceOverService(error); + if (nil == service) { + return NO; + } + + if (![service respondsToSelector:NSSelectorFromString(@"isEnabled")] && + ![service respondsToSelector:NSSelectorFromString(@"enabled")]) { + return FBVoiceOverBuildSDKUnsupportedError(error); + } + + return [[service valueForKey:@"enabled"] boolValue]; +} + +- (nullable NSString *)fb_voiceOverMove:(NSString *)direction error:(NSError **)error +{ + if (![direction isKindOfClass:NSString.class] || 0 == direction.length) { + return [[[FBErrorBuilder builder] + withDescription:@"VoiceOver move direction must be a non-empty string"] + buildError:error], nil; + } + + NSString *normalizedDirection = direction.lowercaseString; + NSString *selectorName = FBVoiceOverMoveSelectors()[normalizedDirection]; + if (nil == selectorName) { + NSArray *supportedDirections = [FBVoiceOverMoveSelectors().allKeys sortedArrayUsingSelector:@selector(compare:)]; + return [[[FBErrorBuilder builder] + withDescriptionFormat:@"Unsupported VoiceOver move direction '%@'. Supported directions: %@", + direction, supportedDirections] + buildError:error], nil; + } + + return FBVoiceOverSpeechFromSelector(NSSelectorFromString(selectorName), error); +} + +- (nullable NSString *)fb_voiceOverCurrentSpeech:(NSError **)error +{ + return FBVoiceOverSpeechFromSelector(NSSelectorFromString(@"currentSpeechAndReturnError:"), error); +} + +@end diff --git a/WebDriverAgentLib/Categories/XCUIElement+FBUtilities.m b/WebDriverAgentLib/Categories/XCUIElement+FBUtilities.m index b627fc862..35b9b7da5 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBUtilities.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBUtilities.m @@ -113,7 +113,7 @@ @implementation XCUIElement (FBUtilities) XCUIElementType type = XCUIElementTypeAny; NSArray *uniqueTypes = [snapshots valueForKeyPath:[NSString stringWithFormat:@"@distinctUnionOfObjects.%@", FBStringify(XCUIElement, elementType)]]; if (uniqueTypes && [uniqueTypes count] == 1) { - type = [uniqueTypes.firstObject intValue]; + type = (XCUIElementType)[uniqueTypes.firstObject intValue]; } XCUIElementQuery *query = onlyChildren ? [self.fb_query childrenMatchingType:type] diff --git a/WebDriverAgentLib/Categories/XCUIElement+FBVisibleFrame.m b/WebDriverAgentLib/Categories/XCUIElement+FBVisibleFrame.m index 8ba5b0eac..09ce6a769 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBVisibleFrame.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBVisibleFrame.m @@ -29,13 +29,19 @@ - (CGRect)fb_visibleFrame { CGRect thisVisibleFrame = [self visibleFrame]; if (!CGRectIsEmpty(thisVisibleFrame)) { - return thisVisibleFrame; + return CGRectMake(CGRectGetMinX(thisVisibleFrame), + CGRectGetMinY(thisVisibleFrame), + CGRectGetWidth(thisVisibleFrame), + CGRectGetHeight(thisVisibleFrame)); } NSDictionary *visibleFrameDict = [self fb_attributeValue:FB_XCAXAVisibleFrameAttributeName error:nil]; if (nil == visibleFrameDict) { - return thisVisibleFrame; + return CGRectMake(CGRectGetMinX(thisVisibleFrame), + CGRectGetMinY(thisVisibleFrame), + CGRectGetWidth(thisVisibleFrame), + CGRectGetHeight(thisVisibleFrame)); } id x = [visibleFrameDict objectForKey:@"X"]; @@ -46,7 +52,10 @@ - (CGRect)fb_visibleFrame return CGRectMake([x doubleValue], [y doubleValue], [width doubleValue], [height doubleValue]); } - return thisVisibleFrame; + return CGRectMake(CGRectGetMinX(thisVisibleFrame), + CGRectGetMinY(thisVisibleFrame), + CGRectGetWidth(thisVisibleFrame), + CGRectGetHeight(thisVisibleFrame)); } @end diff --git a/WebDriverAgentLib/Categories/XCUIElement+FBWebDriverAttributes.m b/WebDriverAgentLib/Categories/XCUIElement+FBWebDriverAttributes.m index a80db0218..361929261 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBWebDriverAttributes.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBWebDriverAttributes.m @@ -202,6 +202,11 @@ - (BOOL)isWDFocused return self.hasFocus; } +- (BOOL)isWDNativeAccessibilityElement +{ + return self.fb_isAccessibilityElement; +} + - (BOOL)isWDAccessible { XCUIElementType elementType = self.elementType; diff --git a/WebDriverAgentLib/Commands/FBCustomCommands.m b/WebDriverAgentLib/Commands/FBCustomCommands.m index d20c485d0..4c4803ff3 100644 --- a/WebDriverAgentLib/Commands/FBCustomCommands.m +++ b/WebDriverAgentLib/Commands/FBCustomCommands.m @@ -26,6 +26,7 @@ #import "XCUIApplication.h" #import "XCUIApplication+FBHelpers.h" #import "XCUIDevice+FBHelpers.h" +#import "XCUIDevice+FBVoiceOver.h" #import "XCUIElement.h" #import "XCUIElement+FBIsVisible.h" #import "XCUIElementQuery.h" @@ -81,6 +82,16 @@ + (NSArray *)routes [[FBRoute DELETE:@"/wda/simulatedLocation"] respondWithTarget:self action:@selector(handleClearSimulatedLocation:)], [[FBRoute DELETE:@"/wda/simulatedLocation"].withoutSession respondWithTarget:self action:@selector(handleClearSimulatedLocation:)], #endif + [[FBRoute POST:@"/wda/voiceOver/enable"] respondWithTarget:self action:@selector(handleVoiceOverEnable:)], + [[FBRoute POST:@"/wda/voiceOver/enable"].withoutSession respondWithTarget:self action:@selector(handleVoiceOverEnable:)], + [[FBRoute POST:@"/wda/voiceOver/disable"] respondWithTarget:self action:@selector(handleVoiceOverDisable:)], + [[FBRoute POST:@"/wda/voiceOver/disable"].withoutSession respondWithTarget:self action:@selector(handleVoiceOverDisable:)], + [[FBRoute GET:@"/wda/voiceOver/enabled"] respondWithTarget:self action:@selector(handleVoiceOverEnabled:)], + [[FBRoute GET:@"/wda/voiceOver/enabled"].withoutSession respondWithTarget:self action:@selector(handleVoiceOverEnabled:)], + [[FBRoute POST:@"/wda/voiceOver/move"] respondWithTarget:self action:@selector(handleVoiceOverMove:)], + [[FBRoute POST:@"/wda/voiceOver/move"].withoutSession respondWithTarget:self action:@selector(handleVoiceOverMove:)], + [[FBRoute GET:@"/wda/voiceOver/currentSpeech"] respondWithTarget:self action:@selector(handleVoiceOverCurrentSpeech:)], + [[FBRoute GET:@"/wda/voiceOver/currentSpeech"].withoutSession respondWithTarget:self action:@selector(handleVoiceOverCurrentSpeech:)], [[FBRoute OPTIONS:@"/*"].withoutSession respondWithTarget:self action:@selector(handlePingCommand:)], ]; } @@ -253,7 +264,7 @@ + (NSDictionary *)processArguments:(XCUIApplication *)app if (nil == result) { return FBResponseWithUnknownError(error); } - return FBResponseWithObject([result base64EncodedStringWithOptions:0]); + return FBResponseWithObject([result base64EncodedStringWithOptions:(NSDataBase64EncodingOptions)0]); } + (id)handleGetBatteryInfo:(FBRouteRequest *)request @@ -599,7 +610,7 @@ + (NSString *)timeZone modifierFlags = [(NSNumber *)modifiers unsignedIntValue]; } NSString *keyValue = [FBKeyboard keyValueForName:item] ?: key; - [destination typeKey:keyValue modifierFlags:modifierFlags]; + [destination typeKey:keyValue modifierFlags:(XCUIKeyModifierFlags)modifierFlags]; } else { NSString *message = @"All items of the 'keys' array must be either dictionaries or strings"; return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:message @@ -611,6 +622,75 @@ + (NSString *)timeZone #endif #endif ++ (id)fb_handleVoiceOverSpeechResponse:(nullable NSString *)utterance + error:(NSError *)error +{ + if (nil != error) { + return FBResponseWithStatus([FBCommandStatus unknownErrorWithMessage:error.description + traceback:nil]); + } + return FBResponseWithObject(@{ + @"utterance": utterance ?: NSNull.null, + }); +} + ++ (id)handleVoiceOverEnable:(FBRouteRequest *)request +{ + NSError *error; + if (![XCUIDevice.sharedDevice fb_enableVoiceOver:&error]) { + return FBResponseWithStatus([FBCommandStatus unknownErrorWithMessage:error.description + traceback:nil]); + } + return FBResponseWithOK(); +} + ++ (id)handleVoiceOverDisable:(FBRouteRequest *)request +{ + NSError *error; + if (![XCUIDevice.sharedDevice fb_disableVoiceOver:&error]) { + return FBResponseWithStatus([FBCommandStatus unknownErrorWithMessage:error.description + traceback:nil]); + } + return FBResponseWithOK(); +} + ++ (id)handleVoiceOverEnabled:(FBRouteRequest *)request +{ + NSError *error; + BOOL isEnabled = [XCUIDevice.sharedDevice fb_isVoiceOverEnabled:&error]; + if (nil != error) { + return FBResponseWithStatus([FBCommandStatus unknownErrorWithMessage:error.description + traceback:nil]); + } + return FBResponseWithObject(@{@"enabled": @(isEnabled)}); +} + ++ (id)handleVoiceOverMove:(FBRouteRequest *)request +{ + NSString *direction = request.arguments[@"direction"]; + if (nil == direction) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"The 'direction' argument must be provided" + traceback:nil]); + } + + NSError *error; + NSString *utterance = [XCUIDevice.sharedDevice fb_voiceOverMove:direction error:&error]; + if (nil != error) { + FBCommandStatus *status = [error.localizedDescription containsString:@"Unsupported VoiceOver move direction"] + ? [FBCommandStatus invalidArgumentErrorWithMessage:error.description traceback:nil] + : [FBCommandStatus unknownErrorWithMessage:error.description traceback:nil]; + return FBResponseWithStatus(status); + } + return [self fb_handleVoiceOverSpeechResponse:utterance error:nil]; +} + ++ (id)handleVoiceOverCurrentSpeech:(FBRouteRequest *)request +{ + NSError *error; + NSString *utterance = [XCUIDevice.sharedDevice fb_voiceOverCurrentSpeech:&error]; + return [self fb_handleVoiceOverSpeechResponse:utterance error:error]; +} + + (id)handlePerformAccessibilityAudit:(FBRouteRequest *)request { NSError *error; diff --git a/WebDriverAgentLib/Commands/FBElementCommands.m b/WebDriverAgentLib/Commands/FBElementCommands.m index 9c2ea009c..3efaf9ab2 100644 --- a/WebDriverAgentLib/Commands/FBElementCommands.m +++ b/WebDriverAgentLib/Commands/FBElementCommands.m @@ -574,7 +574,7 @@ + (NSArray *)routes traceback:nil]); } } - NSString *screenshot = [screenshotData base64EncodedStringWithOptions:0]; + NSString *screenshot = [screenshotData base64EncodedStringWithOptions:(NSDataBase64EncodingOptions)0]; screenshotData = nil; return FBResponseWithObject(screenshot); } diff --git a/WebDriverAgentLib/Commands/FBOrientationCommands.m b/WebDriverAgentLib/Commands/FBOrientationCommands.m index 8e0bea439..bfadd984d 100644 --- a/WebDriverAgentLib/Commands/FBOrientationCommands.m +++ b/WebDriverAgentLib/Commands/FBOrientationCommands.m @@ -139,7 +139,7 @@ + (BOOL)setDeviceOrientation:(NSString *)orientation forApplication:(XCUIApplica if (orientationValue == nil) { return NO; } - return [[XCUIDevice sharedDevice] fb_setDeviceInterfaceOrientation:orientationValue.integerValue]; + return [[XCUIDevice sharedDevice] fb_setDeviceInterfaceOrientation:(UIDeviceOrientation)orientationValue.integerValue]; } + (NSDictionary *)_orientationsMapping diff --git a/WebDriverAgentLib/Commands/FBScreenshotCommands.m b/WebDriverAgentLib/Commands/FBScreenshotCommands.m index 71d6ba594..e2b090722 100644 --- a/WebDriverAgentLib/Commands/FBScreenshotCommands.m +++ b/WebDriverAgentLib/Commands/FBScreenshotCommands.m @@ -33,7 +33,7 @@ + (NSArray *)routes if (nil == screenshotData) { return FBResponseWithStatus([FBCommandStatus unableToCaptureScreenErrorWithMessage:error.description traceback:nil]); } - NSString *screenshot = [screenshotData base64EncodedStringWithOptions:0]; + NSString *screenshot = [screenshotData base64EncodedStringWithOptions:(NSDataBase64EncodingOptions)0]; return FBResponseWithObject(screenshot); } diff --git a/WebDriverAgentLib/Commands/FBSessionCommands.m b/WebDriverAgentLib/Commands/FBSessionCommands.m index 76e43279b..c567381ab 100644 --- a/WebDriverAgentLib/Commands/FBSessionCommands.m +++ b/WebDriverAgentLib/Commands/FBSessionCommands.m @@ -9,7 +9,6 @@ #import "FBSessionCommands.h" #import "FBCapabilities.h" -#import "FBClassChainQueryParser.h" #import "FBConfiguration.h" #import "FBExceptions.h" #import "FBLogger.h" @@ -17,8 +16,8 @@ #import "FBRouteRequest.h" #import "FBSession.h" #import "FBSettings.h" +#import "FBSettingsHandler.h" #import "FBRuntimeUtils.h" -#import "FBActiveAppDetectionPoint.h" #import "FBXCodeCompatibility.h" #import "XCUIApplication+FBHelpers.h" #import "XCUIApplication+FBQuiescence.h" @@ -91,135 +90,35 @@ + (NSArray *)routes } NSDictionary *capabilities; - NSError *error; - if (![request.arguments[@"capabilities"] isKindOfClass:NSDictionary.class]) { - return FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:@"'capabilities' is mandatory to create a new session" - traceback:nil]); - } - if (nil == (capabilities = FBParseCapabilities((NSDictionary *)request.arguments[@"capabilities"], &error))) { - return FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:error.localizedDescription traceback:nil]); - } - - [FBConfiguration resetSessionSettings]; - [FBConfiguration setShouldUseTestManagerForVisibilityDetection:[capabilities[FB_CAP_USE_TEST_MANAGER_FOR_VISIBLITY_DETECTION] boolValue]]; - if (capabilities[FB_SETTING_USE_COMPACT_RESPONSES]) { - [FBConfiguration setShouldUseCompactResponses:[capabilities[FB_SETTING_USE_COMPACT_RESPONSES] boolValue]]; - } - NSString *elementResponseAttributes = capabilities[FB_SETTING_ELEMENT_RESPONSE_ATTRIBUTES]; - if (elementResponseAttributes) { - [FBConfiguration setElementResponseAttributes:elementResponseAttributes]; - } - if (capabilities[FB_CAP_MAX_TYPING_FREQUENCY]) { - [FBConfiguration setMaxTypingFrequency:[capabilities[FB_CAP_MAX_TYPING_FREQUENCY] unsignedIntegerValue]]; - } - if (capabilities[FB_CAP_USE_SINGLETON_TEST_MANAGER]) { - [FBConfiguration setShouldUseSingletonTestManager:[capabilities[FB_CAP_USE_SINGLETON_TEST_MANAGER] boolValue]]; - } - if (capabilities[FB_CAP_DISABLE_AUTOMATIC_SCREENSHOTS]) { - if ([capabilities[FB_CAP_DISABLE_AUTOMATIC_SCREENSHOTS] boolValue]) { - [FBConfiguration disableScreenshots]; - } else { - [FBConfiguration enableScreenshots]; - } - } - if (capabilities[FB_CAP_SHOULD_TERMINATE_APP]) { - [FBConfiguration setShouldTerminateApp:[capabilities[FB_CAP_SHOULD_TERMINATE_APP] boolValue]]; - } - NSNumber *delay = capabilities[FB_CAP_EVENT_LOOP_IDLE_DELAY_SEC]; - if ([delay doubleValue] > 0.0) { - [XCUIApplicationProcessDelay setEventLoopHasIdledDelay:[delay doubleValue]]; - } else { - [XCUIApplicationProcessDelay disableEventLoopDelay]; + id errorResponse = [self capabilitiesFromCreateSessionRequest:request + capabilitiesOut:&capabilities]; + if (nil != errorResponse) { + return errorResponse; } - if (nil != capabilities[FB_SETTING_WAIT_FOR_IDLE_TIMEOUT]) { - FBConfiguration.waitForIdleTimeout = [capabilities[FB_SETTING_WAIT_FOR_IDLE_TIMEOUT] doubleValue]; - } - - if (nil == capabilities[FB_CAP_FORCE_SIMULATOR_SOFTWARE_KEYBOARD_PRESENCE] || - [capabilities[FB_CAP_FORCE_SIMULATOR_SOFTWARE_KEYBOARD_PRESENCE] boolValue]) { - [FBConfiguration forceSimulatorSoftwareKeyboardPresence]; - } + [self applyConfigurationFromCapabilities:capabilities]; NSString *bundleID = capabilities[FB_CAP_BUNDLE_ID]; NSString *initialUrl = capabilities[FB_CAP_INITIAL_URL]; XCUIApplication *app = nil; - if (bundleID != nil) { - app = [[XCUIApplication alloc] initWithBundleIdentifier:bundleID]; - BOOL forceAppLaunch = YES; - if (nil != capabilities[FB_CAP_FORCE_APP_LAUNCH]) { - forceAppLaunch = [capabilities[FB_CAP_FORCE_APP_LAUNCH] boolValue]; - } - XCUIApplicationState appState = app.state; - BOOL isAppRunning = appState >= XCUIApplicationStateRunningBackground; - if (!isAppRunning || (isAppRunning && forceAppLaunch)) { - app.fb_shouldWaitForQuiescence = nil == capabilities[FB_CAP_SHOULD_WAIT_FOR_QUIESCENCE] - || [capabilities[FB_CAP_SHOULD_WAIT_FOR_QUIESCENCE] boolValue]; - app.launchArguments = (NSArray *)capabilities[FB_CAP_ARGUMENTS] ?: @[]; - app.launchEnvironment = (NSDictionary *)capabilities[FB_CAP_ENVIRNOMENT] ?: @{}; - if (nil != initialUrl) { - if (app.running) { - [app terminate]; - } - id errorResponse = [self openDeepLink:initialUrl - withApplication:bundleID - timeout:capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC]]; - if (nil != errorResponse) { - return errorResponse; - } - } else { - NSTimeInterval defaultTimeout = _XCTApplicationStateTimeout(); - if (nil != capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC]) { - _XCTSetApplicationStateTimeout([capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC] doubleValue]); - } - @try { - [app launch]; - } @catch (NSException *e) { - return FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:e.reason traceback:nil]); - } @finally { - if (nil != capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC]) { - _XCTSetApplicationStateTimeout(defaultTimeout); - } - } - } - if (!app.running) { - NSString *errorMsg = [NSString stringWithFormat:@"Cannot launch %@ application. Make sure the correct bundle identifier has been provided in capabilities and check the device log for possible crash report occurrences", bundleID]; - return FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:errorMsg - traceback:nil]); - } - } else if (appState == XCUIApplicationStateRunningBackground && !forceAppLaunch) { - if (nil != initialUrl) { - id errorResponse = [self openDeepLink:initialUrl - withApplication:bundleID - timeout:nil]; - if (nil != errorResponse) { - return errorResponse; - } - } else { - [app activate]; - } - } + errorResponse = [self prepareApplicationForSessionWithBundleID:bundleID + initialUrl:initialUrl + capabilities:capabilities + application:&app]; + if (nil != errorResponse) { + return errorResponse; } if (nil != initialUrl && nil == bundleID) { - id errorResponse = [self openDeepLink:initialUrl - withApplication:nil - timeout:capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC]]; + errorResponse = [self openDeepLink:initialUrl + withApplication:nil + timeout:capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC]]; if (nil != errorResponse) { return errorResponse; } } - if (capabilities[FB_SETTING_DEFAULT_ALERT_ACTION]) { - [FBSession initWithApplication:app - defaultAlertAction:(id)capabilities[FB_SETTING_DEFAULT_ALERT_ACTION]]; - } else { - [FBSession initWithApplication:app]; - } - - if (nil != capabilities[FB_CAP_USE_NATIVE_CACHING_STRATEGY]) { - FBSession.activeSession.useNativeCachingStrategy = [capabilities[FB_CAP_USE_NATIVE_CACHING_STRATEGY] boolValue]; - } + [self initializeSessionWithApplication:app capabilities:capabilities]; return FBResponseWithObject(FBSessionCommands.sessionInformation); } @@ -325,204 +224,192 @@ + (NSArray *)routes + (id)handleGetSettings:(FBRouteRequest *)request { - return FBResponseWithObject( - @{ - FB_SETTING_USE_COMPACT_RESPONSES: @([FBConfiguration shouldUseCompactResponses]), - FB_SETTING_ELEMENT_RESPONSE_ATTRIBUTES: [FBConfiguration elementResponseAttributes], - FB_SETTING_MJPEG_SERVER_SCREENSHOT_QUALITY: @([FBConfiguration mjpegServerScreenshotQuality]), - FB_SETTING_MJPEG_SERVER_FRAMERATE: @([FBConfiguration mjpegServerFramerate]), - FB_SETTING_MJPEG_SCALING_FACTOR: @([FBConfiguration mjpegScalingFactor]), - FB_SETTING_MJPEG_FIX_ORIENTATION: @([FBConfiguration mjpegShouldFixOrientation]), - FB_SETTING_SCREENSHOT_QUALITY: @([FBConfiguration screenshotQuality]), - FB_SETTING_KEYBOARD_AUTOCORRECTION: @([FBConfiguration keyboardAutocorrection]), - FB_SETTING_KEYBOARD_PREDICTION: @([FBConfiguration keyboardPrediction]), - FB_SETTING_SNAPSHOT_MAX_DEPTH: @([FBConfiguration snapshotMaxDepth]), - FB_SETTING_SNAPSHOT_MAX_CHILDREN: @([FBConfiguration snapshotMaxChildren]), - FB_SETTING_USE_FIRST_MATCH: @([FBConfiguration useFirstMatch]), - FB_SETTING_WAIT_FOR_IDLE_TIMEOUT: @([FBConfiguration waitForIdleTimeout]), - FB_SETTING_ANIMATION_COOL_OFF_TIMEOUT: @([FBConfiguration animationCoolOffTimeout]), - FB_SETTING_BOUND_ELEMENTS_BY_INDEX: @([FBConfiguration boundElementsByIndex]), - FB_SETTING_REDUCE_MOTION: @([FBConfiguration reduceMotionEnabled]), - FB_SETTING_DEFAULT_ACTIVE_APPLICATION: request.session.defaultActiveApplication, - FB_SETTING_ACTIVE_APP_DETECTION_POINT: FBActiveAppDetectionPoint.sharedInstance.stringCoordinates, - FB_SETTING_INCLUDE_NON_MODAL_ELEMENTS: @([FBConfiguration includeNonModalElements]), - FB_SETTING_ACCEPT_ALERT_BUTTON_SELECTOR: FBConfiguration.acceptAlertButtonSelector, - FB_SETTING_DISMISS_ALERT_BUTTON_SELECTOR: FBConfiguration.dismissAlertButtonSelector, - FB_SETTING_AUTO_CLICK_ALERT_SELECTOR: FBConfiguration.autoClickAlertSelector, - FB_SETTING_DEFAULT_ALERT_ACTION: request.session.defaultAlertAction ?: @"", - FB_SETTING_MAX_TYPING_FREQUENCY: @([FBConfiguration maxTypingFrequency]), - FB_SETTING_RESPECT_SYSTEM_ALERTS: @([FBConfiguration shouldRespectSystemAlerts]), - FB_SETTING_USE_CLEAR_TEXT_SHORTCUT: @([FBConfiguration useClearTextShortcut]), - FB_SETTING_INCLUDE_HITTABLE_IN_PAGE_SOURCE: @([FBConfiguration includeHittableInPageSource]), - FB_SETTING_INCLUDE_NATIVE_FRAME_IN_PAGE_SOURCE: @([FBConfiguration includeNativeFrameInPageSource]), - FB_SETTING_INCLUDE_MIN_MAX_VALUE_IN_PAGE_SOURCE: @([FBConfiguration includeMinMaxValueInPageSource]), - FB_SETTING_INCLUDE_CUSTOM_ACTIONS_IN_PAGE_SOURCE: @([FBConfiguration includeCustomActionsInPageSource]), - FB_SETTING_PRE_WARM_PAGE_SOURCE: @([FBConfiguration preWarmPageSource]), - FB_SETTING_ENFORCE_CUSTOM_SNAPSHOTS: @([FBConfiguration enforceCustomSnapshots]), - FB_SETTING_LIMIT_XPATH_CONTEXT_SCOPE: @([FBConfiguration limitXpathContextScope]), -#if !TARGET_OS_TV - FB_SETTING_SCREENSHOT_ORIENTATION: [FBConfiguration humanReadableScreenshotOrientation], -#endif - } - ); + return FBResponseWithObject([FBSettingsHandler currentSettingsForSession:request.session]); } -// TODO if we get lots more settings, handling them with a series of if-statements will be unwieldy -// and this should be refactored + (id)handleSetSettings:(FBRouteRequest *)request { - NSDictionary* settings = request.arguments[@"settings"]; - - if (nil != [settings objectForKey:FB_SETTING_USE_COMPACT_RESPONSES]) { - [FBConfiguration setShouldUseCompactResponses:[[settings objectForKey:FB_SETTING_USE_COMPACT_RESPONSES] boolValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_ELEMENT_RESPONSE_ATTRIBUTES]) { - [FBConfiguration setElementResponseAttributes:(NSString *)[settings objectForKey:FB_SETTING_ELEMENT_RESPONSE_ATTRIBUTES]]; - } - if (nil != [settings objectForKey:FB_SETTING_MJPEG_SERVER_SCREENSHOT_QUALITY]) { - [FBConfiguration setMjpegServerScreenshotQuality:[[settings objectForKey:FB_SETTING_MJPEG_SERVER_SCREENSHOT_QUALITY] unsignedIntegerValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_MJPEG_SERVER_FRAMERATE]) { - [FBConfiguration setMjpegServerFramerate:[[settings objectForKey:FB_SETTING_MJPEG_SERVER_FRAMERATE] unsignedIntegerValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_SCREENSHOT_QUALITY]) { - [FBConfiguration setScreenshotQuality:[[settings objectForKey:FB_SETTING_SCREENSHOT_QUALITY] unsignedIntegerValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_MJPEG_SCALING_FACTOR]) { - [FBConfiguration setMjpegScalingFactor:[[settings objectForKey:FB_SETTING_MJPEG_SCALING_FACTOR] floatValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_MJPEG_FIX_ORIENTATION]) { - [FBConfiguration setMjpegShouldFixOrientation:[[settings objectForKey:FB_SETTING_MJPEG_FIX_ORIENTATION] boolValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_KEYBOARD_AUTOCORRECTION]) { - [FBConfiguration setKeyboardAutocorrection:[[settings objectForKey:FB_SETTING_KEYBOARD_AUTOCORRECTION] boolValue]]; + id settingsArgument = request.arguments[@"settings"]; + if (nil != settingsArgument && ![settingsArgument isKindOfClass:NSDictionary.class]) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"settings must be a dictionary" + traceback:nil]); } - if (nil != [settings objectForKey:FB_SETTING_KEYBOARD_PREDICTION]) { - [FBConfiguration setKeyboardPrediction:[[settings objectForKey:FB_SETTING_KEYBOARD_PREDICTION] boolValue]]; + NSDictionary *settings = settingsArgument ?: @{}; + FBCommandStatus *status = [FBSettingsHandler applySettings:settings + toSession:request.session]; + if (status.hasError) { + return FBResponseWithStatus(status); } - if (nil != [settings objectForKey:FB_SETTING_RESPECT_SYSTEM_ALERTS]) { - [FBConfiguration setShouldRespectSystemAlerts:[[settings objectForKey:FB_SETTING_RESPECT_SYSTEM_ALERTS] boolValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_SNAPSHOT_MAX_DEPTH]) { - [FBConfiguration setSnapshotMaxDepth:[[settings objectForKey:FB_SETTING_SNAPSHOT_MAX_DEPTH] intValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_SNAPSHOT_MAX_CHILDREN]) { - [FBConfiguration setSnapshotMaxChildren:[[settings objectForKey:FB_SETTING_SNAPSHOT_MAX_CHILDREN] intValue]]; + return [self handleGetSettings:request]; +} + + +#pragma mark - Session Creation Helpers + ++ (nullable id)capabilitiesFromCreateSessionRequest:(FBRouteRequest *)request + capabilitiesOut:(NSDictionary *_Nonnull *_Nonnull)capabilitiesOut +{ + if (![request.arguments[@"capabilities"] isKindOfClass:NSDictionary.class]) { + return FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:@"'capabilities' is mandatory to create a new session" + traceback:nil]); } - if (nil != [settings objectForKey:FB_SETTING_USE_FIRST_MATCH]) { - [FBConfiguration setUseFirstMatch:[[settings objectForKey:FB_SETTING_USE_FIRST_MATCH] boolValue]]; + NSError *error; + NSDictionary *capabilities = FBParseCapabilities((NSDictionary *)request.arguments[@"capabilities"], &error); + if (nil == capabilities) { + return FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:error.localizedDescription traceback:nil]); } - if (nil != [settings objectForKey:FB_SETTING_BOUND_ELEMENTS_BY_INDEX]) { - [FBConfiguration setBoundElementsByIndex:[[settings objectForKey:FB_SETTING_BOUND_ELEMENTS_BY_INDEX] boolValue]]; + *capabilitiesOut = capabilities; + return nil; +} + ++ (void)applyConfigurationFromCapabilities:(NSDictionary *)capabilities +{ + [FBConfiguration resetSessionSettings]; + if (capabilities[FB_SETTING_USE_COMPACT_RESPONSES]) { + [FBConfiguration setShouldUseCompactResponses:[capabilities[FB_SETTING_USE_COMPACT_RESPONSES] boolValue]]; } - if (nil != [settings objectForKey:FB_SETTING_REDUCE_MOTION]) { - [FBConfiguration setReduceMotionEnabled:[[settings objectForKey:FB_SETTING_REDUCE_MOTION] boolValue]]; + NSString *elementResponseAttributes = capabilities[FB_SETTING_ELEMENT_RESPONSE_ATTRIBUTES]; + if (elementResponseAttributes) { + [FBConfiguration setElementResponseAttributes:elementResponseAttributes]; } - if (nil != [settings objectForKey:FB_SETTING_DEFAULT_ACTIVE_APPLICATION]) { - request.session.defaultActiveApplication = (NSString *)[settings objectForKey:FB_SETTING_DEFAULT_ACTIVE_APPLICATION]; + if (capabilities[FB_CAP_MAX_TYPING_FREQUENCY]) { + [FBConfiguration setMaxTypingFrequency:[capabilities[FB_CAP_MAX_TYPING_FREQUENCY] unsignedIntegerValue]]; } - if (nil != [settings objectForKey:FB_SETTING_ACTIVE_APP_DETECTION_POINT]) { - NSError *error; - if (![FBActiveAppDetectionPoint.sharedInstance setCoordinatesWithString:(NSString *)[settings objectForKey:FB_SETTING_ACTIVE_APP_DETECTION_POINT] - error:&error]) { - return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:error.localizedDescription - traceback:nil]); - } + if (capabilities[FB_CAP_USE_SINGLETON_TEST_MANAGER]) { + [FBConfiguration setShouldUseSingletonTestManager:[capabilities[FB_CAP_USE_SINGLETON_TEST_MANAGER] boolValue]]; } - if (nil != [settings objectForKey:FB_SETTING_INCLUDE_NON_MODAL_ELEMENTS]) { - if ([XCUIElement fb_supportsNonModalElementsInclusion]) { - [FBConfiguration setIncludeNonModalElements:[[settings objectForKey:FB_SETTING_INCLUDE_NON_MODAL_ELEMENTS] boolValue]]; + if (capabilities[FB_CAP_DISABLE_AUTOMATIC_SCREENSHOTS]) { + if ([capabilities[FB_CAP_DISABLE_AUTOMATIC_SCREENSHOTS] boolValue]) { + [FBConfiguration disableScreenshots]; } else { - [FBLogger logFmt:@"'%@' settings value cannot be assigned, because non modal elements inclusion is not supported by the current iOS SDK", FB_SETTING_INCLUDE_NON_MODAL_ELEMENTS]; - } - } - if (nil != [settings objectForKey:FB_SETTING_ACCEPT_ALERT_BUTTON_SELECTOR]) { - [FBConfiguration setAcceptAlertButtonSelector:(NSString *)[settings objectForKey:FB_SETTING_ACCEPT_ALERT_BUTTON_SELECTOR]]; - } - if (nil != [settings objectForKey:FB_SETTING_DISMISS_ALERT_BUTTON_SELECTOR]) { - [FBConfiguration setDismissAlertButtonSelector:(NSString *)[settings objectForKey:FB_SETTING_DISMISS_ALERT_BUTTON_SELECTOR]]; - } - if (nil != [settings objectForKey:FB_SETTING_AUTO_CLICK_ALERT_SELECTOR]) { - FBCommandStatus *status = [self.class configureAutoClickAlertWithSelector:settings[FB_SETTING_AUTO_CLICK_ALERT_SELECTOR] - forSession:request.session]; - if (status.hasError) { - return FBResponseWithStatus(status); + [FBConfiguration enableScreenshots]; } } - if (nil != [settings objectForKey:FB_SETTING_WAIT_FOR_IDLE_TIMEOUT]) { - [FBConfiguration setWaitForIdleTimeout:[[settings objectForKey:FB_SETTING_WAIT_FOR_IDLE_TIMEOUT] doubleValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_ANIMATION_COOL_OFF_TIMEOUT]) { - [FBConfiguration setAnimationCoolOffTimeout:[[settings objectForKey:FB_SETTING_ANIMATION_COOL_OFF_TIMEOUT] doubleValue]]; - } - if ([[settings objectForKey:FB_SETTING_DEFAULT_ALERT_ACTION] isKindOfClass:NSString.class]) { - request.session.defaultAlertAction = [settings[FB_SETTING_DEFAULT_ALERT_ACTION] lowercaseString]; - } - if (nil != [settings objectForKey:FB_SETTING_MAX_TYPING_FREQUENCY]) { - [FBConfiguration setMaxTypingFrequency:[[settings objectForKey:FB_SETTING_MAX_TYPING_FREQUENCY] unsignedIntegerValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_USE_CLEAR_TEXT_SHORTCUT]) { - [FBConfiguration setUseClearTextShortcut:[[settings objectForKey:FB_SETTING_USE_CLEAR_TEXT_SHORTCUT] boolValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_INCLUDE_HITTABLE_IN_PAGE_SOURCE]) { - [FBConfiguration setIncludeHittableInPageSource:[[settings objectForKey:FB_SETTING_INCLUDE_HITTABLE_IN_PAGE_SOURCE] boolValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_INCLUDE_NATIVE_FRAME_IN_PAGE_SOURCE]) { - [FBConfiguration setIncludeNativeFrameInPageSource:[[settings objectForKey:FB_SETTING_INCLUDE_NATIVE_FRAME_IN_PAGE_SOURCE] boolValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_INCLUDE_MIN_MAX_VALUE_IN_PAGE_SOURCE]) { - [FBConfiguration setIncludeMinMaxValueInPageSource:[[settings objectForKey:FB_SETTING_INCLUDE_MIN_MAX_VALUE_IN_PAGE_SOURCE] boolValue]]; - } - if (nil != [settings objectForKey:FB_SETTING_INCLUDE_CUSTOM_ACTIONS_IN_PAGE_SOURCE]) { - [FBConfiguration setIncludeCustomActionsInPageSource:[[settings objectForKey:FB_SETTING_INCLUDE_CUSTOM_ACTIONS_IN_PAGE_SOURCE] boolValue]]; + if (capabilities[FB_CAP_SHOULD_TERMINATE_APP]) { + [FBConfiguration setShouldTerminateApp:[capabilities[FB_CAP_SHOULD_TERMINATE_APP] boolValue]]; } - if (nil != [settings objectForKey:FB_SETTING_PRE_WARM_PAGE_SOURCE]) { - [FBConfiguration setPreWarmPageSource:[[settings objectForKey:FB_SETTING_PRE_WARM_PAGE_SOURCE] boolValue]]; + NSNumber *delay = capabilities[FB_CAP_EVENT_LOOP_IDLE_DELAY_SEC]; + if ([delay doubleValue] > 0.0) { + [XCUIApplicationProcessDelay setEventLoopHasIdledDelay:[delay doubleValue]]; + } else { + [XCUIApplicationProcessDelay disableEventLoopDelay]; } - if (nil != [settings objectForKey:FB_SETTING_ENFORCE_CUSTOM_SNAPSHOTS]) { - [FBConfiguration setEnforceCustomSnapshots:[[settings objectForKey:FB_SETTING_ENFORCE_CUSTOM_SNAPSHOTS] boolValue]]; + if (nil != capabilities[FB_SETTING_WAIT_FOR_IDLE_TIMEOUT]) { + FBConfiguration.waitForIdleTimeout = [capabilities[FB_SETTING_WAIT_FOR_IDLE_TIMEOUT] doubleValue]; } - if (nil != [settings objectForKey:FB_SETTING_LIMIT_XPATH_CONTEXT_SCOPE]) { - [FBConfiguration setLimitXpathContextScope:[[settings objectForKey:FB_SETTING_LIMIT_XPATH_CONTEXT_SCOPE] boolValue]]; + if (nil == capabilities[FB_CAP_FORCE_SIMULATOR_SOFTWARE_KEYBOARD_PRESENCE] || + [capabilities[FB_CAP_FORCE_SIMULATOR_SOFTWARE_KEYBOARD_PRESENCE] boolValue]) { + [FBConfiguration forceSimulatorSoftwareKeyboardPresence]; } +} -#if !TARGET_OS_TV - if (nil != [settings objectForKey:FB_SETTING_SCREENSHOT_ORIENTATION]) { - NSError *error; - if (![FBConfiguration setScreenshotOrientation:(NSString *)[settings objectForKey:FB_SETTING_SCREENSHOT_ORIENTATION] - error:&error]) { - return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:error.localizedDescription - traceback:nil]); ++ (nullable id)prepareApplicationForSessionWithBundleID:(nullable NSString *)bundleID + initialUrl:(nullable NSString *)initialUrl + capabilities:(NSDictionary *)capabilities + application:(XCUIApplication *_Nullable *_Nonnull)applicationOut +{ + if (nil == bundleID) { + *applicationOut = nil; + return nil; + } + + XCUIApplication *app = [[XCUIApplication alloc] initWithBundleIdentifier:bundleID]; + BOOL forceAppLaunch = nil == capabilities[FB_CAP_FORCE_APP_LAUNCH] + || [capabilities[FB_CAP_FORCE_APP_LAUNCH] boolValue]; + XCUIApplicationState appState = app.state; + BOOL isAppRunning = appState >= XCUIApplicationStateRunningBackground; + + if (!isAppRunning || (isAppRunning && forceAppLaunch)) { + id errorResponse = [self launchApplication:app + bundleID:bundleID + initialUrl:initialUrl + capabilities:capabilities]; + if (nil != errorResponse) { + return errorResponse; + } + } else if (appState == XCUIApplicationStateRunningBackground && !forceAppLaunch) { + id errorResponse = [self activateBackgroundApplication:app + bundleID:bundleID + initialUrl:initialUrl]; + if (nil != errorResponse) { + return errorResponse; } } -#endif - return [self handleGetSettings:request]; + *applicationOut = app; + return nil; } ++ (nullable id)launchApplication:(XCUIApplication *)app + bundleID:(NSString *)bundleID + initialUrl:(nullable NSString *)initialUrl + capabilities:(NSDictionary *)capabilities +{ + app.fb_shouldWaitForQuiescence = nil == capabilities[FB_CAP_SHOULD_WAIT_FOR_QUIESCENCE] + || [capabilities[FB_CAP_SHOULD_WAIT_FOR_QUIESCENCE] boolValue]; + app.launchArguments = (NSArray *)capabilities[FB_CAP_ARGUMENTS] ?: @[]; + app.launchEnvironment = (NSDictionary *)capabilities[FB_CAP_ENVIRNOMENT] ?: @{}; + + if (nil != initialUrl) { + if (app.running) { + [app terminate]; + } + id errorResponse = [self openDeepLink:initialUrl + withApplication:bundleID + timeout:capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC]]; + if (nil != errorResponse) { + return errorResponse; + } + } else { + NSTimeInterval defaultTimeout = _XCTApplicationStateTimeout(); + if (nil != capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC]) { + _XCTSetApplicationStateTimeout([capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC] doubleValue]); + } + @try { + [app launch]; + } @catch (NSException *e) { + return FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:e.reason traceback:nil]); + } @finally { + if (nil != capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC]) { + _XCTSetApplicationStateTimeout(defaultTimeout); + } + } + } -#pragma mark - Helpers + if (!app.running) { + NSString *errorMsg = [NSString stringWithFormat:@"Cannot launch %@ application. Make sure the correct bundle identifier has been provided in capabilities and check the device log for possible crash report occurrences", bundleID]; + return FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:errorMsg traceback:nil]); + } + return nil; +} -+ (FBCommandStatus *)configureAutoClickAlertWithSelector:(NSString *)selector - forSession:(FBSession *)session ++ (nullable id)activateBackgroundApplication:(XCUIApplication *)app + bundleID:(NSString *)bundleID + initialUrl:(nullable NSString *)initialUrl { - if (0 == [selector length]) { - [FBConfiguration setAutoClickAlertSelector:selector]; - [session disableAlertsMonitor]; - return [FBCommandStatus ok]; + if (nil != initialUrl) { + return [self openDeepLink:initialUrl withApplication:bundleID timeout:nil]; } + [app activate]; + return nil; +} - NSError *error; - FBClassChain *parsedChain = [FBClassChainQueryParser parseQuery:selector error:&error]; - if (nil == parsedChain) { - return [FBCommandStatus invalidSelectorErrorWithMessage:error.localizedDescription - traceback:nil]; - } - [FBConfiguration setAutoClickAlertSelector:selector]; - [session enableAlertsMonitor]; - return [FBCommandStatus ok]; ++ (void)initializeSessionWithApplication:(nullable XCUIApplication *)app + capabilities:(NSDictionary *)capabilities +{ + if (capabilities[FB_SETTING_DEFAULT_ALERT_ACTION]) { + [FBSession initWithApplication:app + defaultAlertAction:(id)capabilities[FB_SETTING_DEFAULT_ALERT_ACTION]]; + } else { + [FBSession initWithApplication:app]; + } + if (nil != capabilities[FB_CAP_USE_NATIVE_CACHING_STRATEGY]) { + FBSession.activeSession.useNativeCachingStrategy = [capabilities[FB_CAP_USE_NATIVE_CACHING_STRATEGY] boolValue]; + } } +#pragma mark - Helpers + + (NSString *)buildTimestamp { return [NSString stringWithFormat:@"%@ %@", diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index 2e2de763d..fc620da05 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -267,6 +267,9 @@ - (XCUIElement *)alertElement } else { self.element = systemApp.fb_alertElement ?: self.application.fb_alertElement; } + if (nil == self.element) { + self.element = [XCUIApplication fb_limitedAccessPromptAlertElement]; + } } return self.element; } diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 282c2aefd..9f335d622 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 11.4.1 + 15.0.0 CFBundleSignature ???? CFBundleVersion - 11.4.1 + 15.0.0 NSPrincipalClass diff --git a/WebDriverAgentLib/Routing/FBElement.h b/WebDriverAgentLib/Routing/FBElement.h index 2df9c6644..a1fdb30c7 100644 --- a/WebDriverAgentLib/Routing/FBElement.h +++ b/WebDriverAgentLib/Routing/FBElement.h @@ -55,6 +55,9 @@ NS_ASSUME_NONNULL_BEGIN /*! Whether element is accessible */ @property (nonatomic, readonly, getter = isWDAccessible) BOOL wdAccessible; +/*! The raw, native `isAccessibilityElement` value reported by the accessibility framework, without WebDriverAgent's custom computation applied by `wdAccessible` */ +@property (nonatomic, readonly, getter = isWDNativeAccessibilityElement) BOOL wdNativeAccessibilityElement; + /*! Whether element is an accessibility container (contains children of any depth that are accessible) */ @property (nonatomic, readonly, getter = isWDAccessibilityContainer) BOOL wdAccessibilityContainer; diff --git a/WebDriverAgentLib/Routing/FBScreenRecordingRequest.m b/WebDriverAgentLib/Routing/FBScreenRecordingRequest.m index 32c5b0579..06f9d7790 100644 --- a/WebDriverAgentLib/Routing/FBScreenRecordingRequest.m +++ b/WebDriverAgentLib/Routing/FBScreenRecordingRequest.m @@ -46,7 +46,7 @@ - (nullable id)createVideoEncodingWithError:(NSError **)error [videoEncodingInitInvocation setSelector:videoEncodingConstructorSelector]; long long codec = self.codec; [videoEncodingInitInvocation setArgument:&codec atIndex:2]; - double frameRate = self.fps; + double frameRate = (double)self.fps; [videoEncodingInitInvocation setArgument:&frameRate atIndex:3]; [videoEncodingInitInvocation invokeWithTarget:videoEncodingAllocated]; id __unsafe_unretained result; diff --git a/WebDriverAgentLib/Routing/FBTCPSocket.h b/WebDriverAgentLib/Routing/FBTCPSocket.h index 31adc7e22..72947e2a0 100644 --- a/WebDriverAgentLib/Routing/FBTCPSocket.h +++ b/WebDriverAgentLib/Routing/FBTCPSocket.h @@ -38,7 +38,11 @@ NS_ASSUME_NONNULL_BEGIN @interface FBTCPSocket : NSObject -@property (nullable, nonatomic) id delegate; +#if __has_feature(objc_arc_weak) +@property (nullable, nonatomic, weak) id delegate; +#else +@property (nullable, nonatomic, assign) id delegate; +#endif /** Creates TCP socket isntance which is going to be started on the specified port diff --git a/WebDriverAgentLib/Routing/FBTCPSocket.m b/WebDriverAgentLib/Routing/FBTCPSocket.m index fabd22074..e23876d83 100644 --- a/WebDriverAgentLib/Routing/FBTCPSocket.m +++ b/WebDriverAgentLib/Routing/FBTCPSocket.m @@ -39,7 +39,7 @@ - (instancetype)initWithPort:(uint16_t)port - (BOOL)startWithError:(NSError **)error { if (![self.listeningSocket acceptOnPort:self.port error:error]) { - return NO;; + return NO; } return YES; @@ -48,11 +48,14 @@ - (BOOL)startWithError:(NSError **)error - (void)stop { @synchronized(self.connectedClients) { - for (NSUInteger i = 0; i < [self.connectedClients count]; i++) { - [[self.connectedClients objectAtIndex:i] disconnect]; + NSArray *clients = self.connectedClients.copy; + [self.connectedClients removeAllObjects]; + for (GCDAsyncSocket *client in clients) { + [client disconnect]; } } + self.delegate = nil; [self.listeningSocket disconnect]; } @@ -66,12 +69,18 @@ - (void)socket:(GCDAsyncSocket *)sock didAcceptNewSocket:(GCDAsyncSocket *)newSo @synchronized(self.connectedClients) { [self.connectedClients addObject:newSocket]; } - [self.delegate didClientConnect:newSocket]; + id delegate = self.delegate; + if (nil != delegate) { + [delegate didClientConnect:newSocket]; + } } - (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag { - [self.delegate didClientSendData:sock]; + id delegate = self.delegate; + if (nil != delegate) { + [delegate didClientSendData:sock]; + } } - (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err @@ -79,7 +88,10 @@ - (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err @synchronized(self.connectedClients) { [self.connectedClients removeObject:sock]; } - [self.delegate didClientDisconnect:sock]; + id delegate = self.delegate; + if (nil != delegate) { + [delegate didClientDisconnect:sock]; + } } @end diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index 0b82eaff5..5d4d83535 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -39,6 +39,11 @@ - (void)handleResourceNotFound [super handleResourceNotFound]; } +- (UInt64)maxRequestBodySize +{ + return FBConfiguration.httpRequestBodySizeLimit; +} + @end @@ -47,10 +52,16 @@ @interface FBWebServer () @property (nonatomic, strong) RoutingHTTPServer *server; @property (atomic, assign) BOOL keepAlive; @property (nonatomic, nullable) FBTCPSocket *screenshotsBroadcaster; +@property (nonatomic, nullable, strong) FBMjpegServer *mjpegServer; @end @implementation FBWebServer +- (void)dealloc +{ + [self stopScreenshotsBroadcaster]; +} + + (NSArray> *)collectCommandHandlerClasses { NSArray *handlersClasses = FBClassesThatConformsToProtocol(@protocol(FBCommandHandler)); @@ -97,7 +108,7 @@ - (void)startHTTPServer [self.server setInterface:bindingIP]; [FBLogger logFmt:@"Using custom binding IP address: %@", bindingIP]; } - + NSError *error; BOOL serverStarted = NO; @@ -117,7 +128,7 @@ - (void)startHTTPServer [FBLogger logFmt:@"Last attempt to start web server failed with error %@", [error description]]; abort(); } - + NSString *serverHost = bindingIP ?: ([XCUIDevice sharedDevice].fb_wifiIPAddress ?: @"127.0.0.1"); [FBLogger logFmt:@"%@http://%@:%d%@", FBServerURLBeginMarker, serverHost, [self.server port], FBServerURLEndMarker]; } @@ -125,12 +136,15 @@ - (void)startHTTPServer - (void)initScreenshotsBroadcaster { [self readMjpegSettingsFromEnv]; + self.mjpegServer = [[FBMjpegServer alloc] init]; self.screenshotsBroadcaster = [[FBTCPSocket alloc] initWithPort:(uint16_t)FBConfiguration.mjpegServerPort]; - self.screenshotsBroadcaster.delegate = [[FBMjpegServer alloc] init]; + self.screenshotsBroadcaster.delegate = self.mjpegServer; NSError *error; if (![self.screenshotsBroadcaster startWithError:&error]) { [FBLogger logFmt:@"Cannot init screenshots broadcaster service on port %@. Original error: %@", @(FBConfiguration.mjpegServerPort), error.description]; + [self.mjpegServer stopStreaming]; + self.mjpegServer = nil; self.screenshotsBroadcaster = nil; } } @@ -138,10 +152,18 @@ - (void)initScreenshotsBroadcaster - (void)stopScreenshotsBroadcaster { if (nil == self.screenshotsBroadcaster) { + self.mjpegServer = nil; return; } + id delegate = self.screenshotsBroadcaster.delegate; + if ([(NSObject *)delegate respondsToSelector:@selector(stopStreaming)]) { + [(FBMjpegServer *)delegate stopStreaming]; + } + self.screenshotsBroadcaster.delegate = nil; [self.screenshotsBroadcaster stop]; + self.screenshotsBroadcaster = nil; + self.mjpegServer = nil; } - (void)readMjpegSettingsFromEnv @@ -164,6 +186,8 @@ - (void)stopServing if (self.server.isRunning) { [self.server stop:NO]; } + self.server = nil; + self.exceptionHandler = nil; self.keepAlive = NO; } @@ -192,10 +216,15 @@ - (BOOL)attemptToStartServer:(RoutingHTTPServer *)server onPort:(NSInteger)port - (void)registerRouteHandlers:(NSArray *)commandHandlerClasses { + __weak typeof(self) weakSelf = self; for (Class commandHandler in commandHandlerClasses) { NSArray *routes = [commandHandler routes]; for (FBRoute *route in routes) { [self.server handleMethod:route.verb withPath:route.path block:^(RouteRequest *request, RouteResponse *response) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (nil == strongSelf) { + return; + } NSDictionary *arguments = [NSJSONSerialization JSONObjectWithData:request.body options:NSJSONReadingMutableContainers error:NULL]; FBRouteRequest *routeParams = [FBRouteRequest routeRequestWithURL:request.url @@ -209,7 +238,7 @@ - (void)registerRouteHandlers:(NSArray *)commandHandlerClasses [route mountRequest:routeParams intoResponse:response]; } @catch (NSException *exception) { - [self handleException:exception forResponse:response]; + [strongSelf handleException:exception forResponse:response]; } }]; } @@ -237,9 +266,14 @@ - (void)registerServerKeyRouteHandlers [response respondWithString:calibrationPage]; }]; + __weak typeof(self) weakSelf = self; [self.server get:@"/wda/shutdown" withBlock:^(RouteRequest *request, RouteResponse *response) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (nil == strongSelf) { + return; + } [response respondWithString:@"Shutting down"]; - [self.delegate webServerDidRequestShutdown:self]; + [strongSelf.delegate webServerDidRequestShutdown:strongSelf]; }]; [self registerRouteHandlers:@[FBUnknownCommands.class]]; diff --git a/WebDriverAgentLib/Utilities/FBAlertsMonitor.m b/WebDriverAgentLib/Utilities/FBAlertsMonitor.m index 3dd221198..176d4f29f 100644 --- a/WebDriverAgentLib/Utilities/FBAlertsMonitor.m +++ b/WebDriverAgentLib/Utilities/FBAlertsMonitor.m @@ -48,22 +48,36 @@ - (void)scheduleNextTick } dispatch_async(dispatch_get_main_queue(), ^{ + id delegate = self.delegate; NSArray *activeApps = XCUIApplication.fb_activeApplications; + BOOL didDetectAlert = NO; for (XCUIApplication *activeApp in activeApps) { XCUIElement *alertElement = nil; @try { alertElement = activeApp.fb_alertElement; if (nil != alertElement) { - [self.delegate didDetectAlert:[FBAlert alertWithElement:alertElement]]; + [delegate didDetectAlert:[FBAlert alertWithElement:alertElement]]; } } @catch (NSException *e) { [FBLogger logFmt:@"Got an unexpected exception while monitoring alerts: %@\n%@", e.reason, e.callStackSymbols]; } if (nil != alertElement) { + didDetectAlert = YES; break; } } + if (!didDetectAlert) { + @try { + XCUIElement *alertElement = [XCUIApplication fb_limitedAccessPromptAlertElement]; + if (nil != alertElement) { + [delegate didDetectAlert:[FBAlert alertWithElement:alertElement]]; + } + } @catch (NSException *e) { + [FBLogger logFmt:@"Got an unexpected exception while monitoring alerts: %@\n%@", e.reason, e.callStackSymbols]; + } + } + if (self.isMonitoring) { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delta), dispatch_get_main_queue(), ^{ [self scheduleNextTick]; diff --git a/WebDriverAgentLib/Utilities/FBCapabilities.h b/WebDriverAgentLib/Utilities/FBCapabilities.h index d1339c7a6..6e7d7e35c 100644 --- a/WebDriverAgentLib/Utilities/FBCapabilities.h +++ b/WebDriverAgentLib/Utilities/FBCapabilities.h @@ -8,8 +8,6 @@ #import -/** Whether to use alternative elements visivility detection method */ -extern NSString* const FB_CAP_USE_TEST_MANAGER_FOR_VISIBLITY_DETECTION; /** Set the maximum amount of characters that could be typed within a minute (60 by default) */ extern NSString* const FB_CAP_MAX_TYPING_FREQUENCY; /** this setting was needed for some legacy stuff */ @@ -26,7 +24,7 @@ extern NSString* const FB_CAP_BUNDLE_ID; Usually an URL used as initial link to run Mobile Safari, but could be any other deep link. This might also work together with `FB_CAP_BUNLDE_ID`, which tells XCTest to open the given deep link in the particular app. - Only works since iOS 16.4 + Only works since iOS 16.4 runtime */ extern NSString* const FB_CAP_INITIAL_URL; /** Whether to enforrce (re)start of the application under test on session startup */ diff --git a/WebDriverAgentLib/Utilities/FBCapabilities.m b/WebDriverAgentLib/Utilities/FBCapabilities.m index 351f23e68..4693fc8a2 100644 --- a/WebDriverAgentLib/Utilities/FBCapabilities.m +++ b/WebDriverAgentLib/Utilities/FBCapabilities.m @@ -8,7 +8,6 @@ #import "FBCapabilities.h" -NSString* const FB_CAP_USE_TEST_MANAGER_FOR_VISIBLITY_DETECTION = @"shouldUseTestManagerForVisibilityDetection"; NSString* const FB_CAP_MAX_TYPING_FREQUENCY = @"maxTypingFrequency"; NSString* const FB_CAP_USE_SINGLETON_TEST_MANAGER = @"shouldUseSingletonTestManager"; NSString* const FB_CAP_DISABLE_AUTOMATIC_SCREENSHOTS = @"disableAutomaticScreenshots"; diff --git a/WebDriverAgentLib/Utilities/FBClassChainQueryParser.m b/WebDriverAgentLib/Utilities/FBClassChainQueryParser.m index 933d0ad89..219706b8f 100644 --- a/WebDriverAgentLib/Utilities/FBClassChainQueryParser.m +++ b/WebDriverAgentLib/Utilities/FBClassChainQueryParser.m @@ -117,7 +117,7 @@ - (void)appendChar:(unichar)character { NSMutableString *value = [NSMutableString stringWithString:self.asString]; [value appendFormat:@"%C", character]; - self.asString = value.copy;; + self.asString = value.copy; } - (nullable FBBaseClassChainToken*)followingTokenBasedOn:(unichar)character diff --git a/WebDriverAgentLib/Utilities/FBConfiguration.h b/WebDriverAgentLib/Utilities/FBConfiguration.h index 784d56a24..9e4c8b996 100644 --- a/WebDriverAgentLib/Utilities/FBConfiguration.h +++ b/WebDriverAgentLib/Utilities/FBConfiguration.h @@ -18,10 +18,6 @@ extern NSString *const FBSnapshotMaxDepthKey; */ @interface FBConfiguration : NSObject -/*! If set to YES will ask TestManagerDaemon for element visibility */ -+ (void)setShouldUseTestManagerForVisibilityDetection:(BOOL)value; -+ (BOOL)shouldUseTestManagerForVisibilityDetection; - /*! If set to YES will use compact (standards-compliant) & faster responses */ + (void)setShouldUseCompactResponses:(BOOL)value; + (BOOL)shouldUseCompactResponses; @@ -136,6 +132,12 @@ extern NSString *const FBSnapshotMaxDepthKey; */ + (NSInteger)mjpegServerPort; +/** + The maximum allowed HTTP request body size in bytes. + Defaults to 1GB and can be overridden with the MAX_HTTP_REQUEST_BODY_SIZE environment variable. + */ ++ (UInt64)httpRequestBodySizeLimit; + /** The scaling factor for frames of the mjpeg stream. The default (and maximum) value is 100, which does not perform any scaling. The minimum value must be greater than zero. @@ -276,17 +278,6 @@ typedef NS_ENUM(NSInteger, FBConfigurationKeyboardPreference) { + (void)setAnimationCoolOffTimeout:(NSTimeInterval)timeout; + (NSTimeInterval)animationCoolOffTimeout; -/** - Enforces the page hierarchy to include non modal elements, - like Contacts. By default such elements are not present there. - See https://github.com/appium/appium/issues/13227 - - @param isEnabled Set to YES in order to enable non modal elements inclusion. - Setting this value to YES will have no effect if the current iOS SDK does not support such feature. - */ -+ (void)setIncludeNonModalElements:(BOOL)isEnabled; -+ (BOOL)includeNonModalElements; - /** Sets custom class chain locators for accept/dismiss alert buttons location. This might be useful if the default buttons detection algorithm fails to determine alert buttons properly @@ -381,6 +372,24 @@ typedef NS_ENUM(NSInteger, FBConfigurationKeyboardPreference) { + (void)setIncludeNativeFrameInPageSource:(BOOL)enabled; + (BOOL)includeNativeFrameInPageSource; +/** + * Whether to include the `nativeAccessibilityElement` attribute in the XML page source. + * + * When enabled, the XML representation will contain the raw, native + * `isAccessibilityElement` value as reported by the accessibility framework, + * without the custom computation that WebDriverAgent applies to the + * `accessible` attribute (cell/text field special cases and parent absorption). + * + * This is useful for consumers that need to reason about the unmodified + * accessibility flag alongside the computed `accessible` value. + * + * The value is disabled by default to keep the default page source stable. + * + * @param enabled Either YES or NO + */ ++ (void)setIncludeNativeAccessibilityElementInPageSource:(BOOL)enabled; ++ (BOOL)includeNativeAccessibilityElementInPageSource; + /** * Whether to include `minValue`/`maxValue` attributes in the page source. * These attributes are retrieved from native element snapshots and represent diff --git a/WebDriverAgentLib/Utilities/FBConfiguration.m b/WebDriverAgentLib/Utilities/FBConfiguration.m index 0788158fb..de63a6223 100644 --- a/WebDriverAgentLib/Utilities/FBConfiguration.m +++ b/WebDriverAgentLib/Utilities/FBConfiguration.m @@ -26,6 +26,7 @@ static NSUInteger const DefaultStartingPort = 8100; static NSUInteger const DefaultMjpegServerPort = 9100; static NSUInteger const DefaultPortRange = 100; +static UInt64 const DefaultHttpRequestBodySizeLimit = 1024ull * 1024ull * 1024ull; static char const *const controllerPrefBundlePath = "/System/Library/PrivateFrameworks/TextInput.framework/TextInput"; static NSString *const controllerClassName = @"TIPreferencesController"; @@ -33,7 +34,6 @@ static NSString *const FBKeyboardPredictionKey = @"KeyboardPrediction"; static NSString *const axSettingsClassName = @"AXSettings"; -static BOOL FBShouldUseTestManagerForVisibilityDetection = NO; static BOOL FBShouldUseSingletonTestManager = YES; static BOOL FBShouldRespectSystemAlerts = NO; @@ -48,7 +48,6 @@ static NSUInteger FBScreenshotQuality; static BOOL FBShouldUseFirstMatch; static BOOL FBShouldBoundElementsByIndex; -static BOOL FBIncludeNonModalElements; static NSString *FBAcceptAlertButtonSelector; static NSString *FBDismissAlertButtonSelector; static NSString *FBAutoClickAlertSelector; @@ -63,6 +62,7 @@ #endif static BOOL FBShouldIncludeHittableInPageSource = NO; static BOOL FBShouldIncludeNativeFrameInPageSource = NO; +static BOOL FBShouldIncludeNativeAccessibilityElementInPageSource = NO; static BOOL FBShouldIncludeMinMaxValueInPageSource = NO; static BOOL FBShouldIncludeCustomActionsInPageSource = NO; static BOOL FBShouldPreWarmPageSource = YES; @@ -166,6 +166,19 @@ + (NSInteger)mjpegServerPort return DefaultMjpegServerPort; } ++ (UInt64)httpRequestBodySizeLimit +{ + NSString *limit = NSProcessInfo.processInfo.environment[@"MAX_HTTP_REQUEST_BODY_SIZE"]; + if (limit.length > 0) { + long long parsedLimit = [limit longLongValue]; + if (parsedLimit > 0) { + return (UInt64)parsedLimit; + } + } + + return DefaultHttpRequestBodySizeLimit; +} + + (CGFloat)mjpegScalingFactor { return FBMjpegScalingFactor; @@ -189,16 +202,6 @@ + (BOOL)verboseLoggingEnabled return [NSProcessInfo.processInfo.environment[@"VERBOSE_LOGGING"] boolValue]; } -+ (void)setShouldUseTestManagerForVisibilityDetection:(BOOL)value -{ - FBShouldUseTestManagerForVisibilityDetection = value; -} - -+ (BOOL)shouldUseTestManagerForVisibilityDetection -{ - return FBShouldUseTestManagerForVisibilityDetection; -} - + (void)setShouldUseCompactResponses:(BOOL)value { FBShouldUseCompactResponses = value; @@ -427,16 +430,6 @@ + (BOOL)boundElementsByIndex return FBShouldBoundElementsByIndex; } -+ (void)setIncludeNonModalElements:(BOOL)isEnabled -{ - FBIncludeNonModalElements = isEnabled; -} - -+ (BOOL)includeNonModalElements -{ - return FBIncludeNonModalElements; -} - + (void)setAcceptAlertButtonSelector:(NSString *)classChainSelector { FBAcceptAlertButtonSelector = classChainSelector; @@ -543,9 +536,6 @@ + (void)resetSessionSettings FBScreenshotQuality = 3; FBShouldUseFirstMatch = NO; FBShouldBoundElementsByIndex = NO; - // This is diabled by default because enabling it prevents the accessbility snapshot to be taken - // (it always errors with kxIllegalArgument error) - FBIncludeNonModalElements = NO; FBAcceptAlertButtonSelector = @""; FBDismissAlertButtonSelector = @""; FBAutoClickAlertSelector = @""; @@ -691,6 +681,16 @@ + (BOOL)includeNativeFrameInPageSource return FBShouldIncludeNativeFrameInPageSource; } ++ (void)setIncludeNativeAccessibilityElementInPageSource:(BOOL)enabled +{ + FBShouldIncludeNativeAccessibilityElementInPageSource = enabled; +} + ++ (BOOL)includeNativeAccessibilityElementInPageSource +{ + return FBShouldIncludeNativeAccessibilityElementInPageSource; +} + + (void)setIncludeMinMaxValueInPageSource:(BOOL)enabled { FBShouldIncludeMinMaxValueInPageSource = enabled; diff --git a/WebDriverAgentLib/Utilities/FBImageProcessor.m b/WebDriverAgentLib/Utilities/FBImageProcessor.m index 7d2ada759..fe197c940 100644 --- a/WebDriverAgentLib/Utilities/FBImageProcessor.m +++ b/WebDriverAgentLib/Utilities/FBImageProcessor.m @@ -25,8 +25,10 @@ @interface FBImageProcessor () @property (nonatomic) NSData *nextImage; +@property (nonatomic) NSMutableArray *pendingCompletionHandlers; @property (nonatomic, readonly) NSLock *nextImageLock; @property (nonatomic, readonly) dispatch_queue_t scalingQueue; +@property (atomic, assign) BOOL isScalingScheduled; @end @@ -37,7 +39,9 @@ - (id)init self = [super init]; if (self) { _nextImageLock = [[NSLock alloc] init]; + _pendingCompletionHandlers = [NSMutableArray array]; _scalingQueue = dispatch_queue_create("image.scaling.queue", NULL); + _isScalingScheduled = NO; } return self; } @@ -51,34 +55,50 @@ - (void)submitImageData:(NSData *)image [FBLogger verboseLog:@"Discarding screenshot"]; } self.nextImage = image; + [self.pendingCompletionHandlers addObject:[completionHandler copy]]; + BOOL shouldSchedule = !self.isScalingScheduled; + if (shouldSchedule) { + self.isScalingScheduled = YES; + } [self.nextImageLock unlock]; + if (!shouldSchedule) { + return; + } -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wcompletion-handler" dispatch_async(self.scalingQueue, ^{ - [self.nextImageLock lock]; - NSData *nextImageData = self.nextImage; - self.nextImage = nil; - [self.nextImageLock unlock]; - if (nextImageData == nil) { - return; - } + while (YES) { + @autoreleasepool { + [self.nextImageLock lock]; + NSData *nextImageData = self.nextImage; + self.nextImage = nil; + NSArray *handlers = [self.pendingCompletionHandlers copy]; + [self.pendingCompletionHandlers removeAllObjects]; + if (nextImageData == nil) { + self.isScalingScheduled = NO; + [self.nextImageLock unlock]; + return; + } + [self.nextImageLock unlock]; - // We do not want this value to be too high because then we get images larger in size than original ones - // Although, we also don't want to lose too much of the quality on recompression - CGFloat recompressionQuality = MAX(0.9, - MIN(FBMaxCompressionQuality, FBConfiguration.mjpegServerScreenshotQuality / 100.0)); - NSData *thumbnailData = [self.class fixedImageDataWithImageData:nextImageData - scalingFactor:scalingFactor - uti:UTTypeJPEG - compressionQuality:recompressionQuality - // iOS always returns screnshots in portrait orientation, but puts the real value into the metadata - // Use it with care. See https://github.com/appium/WebDriverAgent/pull/812 - fixOrientation:FBConfiguration.mjpegShouldFixOrientation - desiredOrientation:nil]; - completionHandler(thumbnailData ?: nextImageData); + // We do not want this value to be too high because then we get images larger in size than original ones + // Although, we also don't want to lose too much of the quality on recompression + CGFloat recompressionQuality = MAX(0.9, + MIN(FBMaxCompressionQuality, (double)FBConfiguration.mjpegServerScreenshotQuality / 100.0)); + NSData *thumbnailData = [self.class fixedImageDataWithImageData:nextImageData + scalingFactor:scalingFactor + uti:UTTypeJPEG + compressionQuality:recompressionQuality + // iOS always returns screenshots in portrait orientation, but puts the real value into the metadata + // Use it with care. See https://github.com/appium/WebDriverAgent/pull/812 + fixOrientation:FBConfiguration.mjpegShouldFixOrientation + desiredOrientation:nil]; + NSData *processedImageData = thumbnailData ?: nextImageData; + for (void (^handler)(NSData *) in handlers) { + handler(processedImageData); + } + } + } }); -#pragma clang diagnostic pop } + (nullable NSData *)fixedImageDataWithImageData:(NSData *)imageData diff --git a/WebDriverAgentLib/Utilities/FBMjpegServer.h b/WebDriverAgentLib/Utilities/FBMjpegServer.h index 294c399f8..a9b47cada 100644 --- a/WebDriverAgentLib/Utilities/FBMjpegServer.h +++ b/WebDriverAgentLib/Utilities/FBMjpegServer.h @@ -19,6 +19,11 @@ NS_ASSUME_NONNULL_BEGIN */ - (instancetype)init; +/** + Stops screenshot broadcasting and prevents future scheduling. + */ +- (void)stopStreaming; + @end NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBMjpegServer.m b/WebDriverAgentLib/Utilities/FBMjpegServer.m index 4f061d2e3..6d6250422 100644 --- a/WebDriverAgentLib/Utilities/FBMjpegServer.m +++ b/WebDriverAgentLib/Utilities/FBMjpegServer.m @@ -27,6 +27,11 @@ static NSString *const SERVER_NAME = @"WDA MJPEG Server"; static const char *QUEUE_NAME = "JPEG Screenshots Provider Queue"; +static NSUInteger FBNormalizedMjpegFramerate(NSUInteger framerate) +{ + return (0 == framerate || framerate > MAX_FPS) ? MAX_FPS : framerate; +} + @interface FBMjpegServer() @@ -35,6 +40,9 @@ @interface FBMjpegServer() @property (nonatomic, readonly) FBImageProcessor *imageProcessor; @property (nonatomic, readonly) long long mainScreenID; @property (nonatomic, assign) NSUInteger consecutiveScreenshotFailures; +@property (atomic, assign) BOOL isStreaming; +@property (nonatomic, assign) NSUInteger sentFramesCount; +@property (nonatomic, assign) NSUInteger sentBytesCount; @end @@ -45,38 +53,49 @@ - (instancetype)init { if ((self = [super init])) { _consecutiveScreenshotFailures = 0; + _isStreaming = YES; + _sentFramesCount = 0; + _sentBytesCount = 0; _listeningClients = [NSMutableArray array]; + _imageProcessor = [[FBImageProcessor alloc] init]; + _mainScreenID = [XCUIScreen.mainScreen displayID]; dispatch_queue_attr_t queueAttributes = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_UTILITY, 0); _backgroundQueue = dispatch_queue_create(QUEUE_NAME, queueAttributes); + __weak typeof(self) weakSelf = self; dispatch_async(_backgroundQueue, ^{ - [self streamScreenshot]; + [weakSelf streamScreenshot]; }); - _imageProcessor = [[FBImageProcessor alloc] init]; - _mainScreenID = [XCUIScreen.mainScreen displayID]; } return self; } - (void)scheduleNextScreenshotWithInterval:(uint64_t)timerInterval timeStarted:(uint64_t)timeStarted { + if (!self.isStreaming) { + return; + } uint64_t timeElapsed = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) - timeStarted; - int64_t nextTickDelta = timerInterval - timeElapsed; + int64_t nextTickDelta = (int64_t)timerInterval - (int64_t)timeElapsed; + __weak typeof(self) weakSelf = self; if (nextTickDelta > 0) { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, nextTickDelta), self.backgroundQueue, ^{ - [self streamScreenshot]; + [weakSelf streamScreenshot]; }); } else { // Try to do our best to keep the FPS at a decent level dispatch_async(self.backgroundQueue, ^{ - [self streamScreenshot]; + [weakSelf streamScreenshot]; }); } } - (void)streamScreenshot { - NSUInteger framerate = FBConfiguration.mjpegServerFramerate; - uint64_t timerInterval = (uint64_t)(1.0 / ((0 == framerate || framerate > MAX_FPS) ? MAX_FPS : framerate) * NSEC_PER_SEC); + if (!self.isStreaming) { + return; + } + NSUInteger framerate = FBNormalizedMjpegFramerate(FBConfiguration.mjpegServerFramerate); + uint64_t timerInterval = (uint64_t)(1.0 / (double)framerate * NSEC_PER_SEC); uint64_t timeStarted = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); @synchronized (self.listeningClients) { if (0 == self.listeningClients.count) { @@ -87,7 +106,7 @@ - (void)streamScreenshot NSError *error; CGFloat compressionQuality = MAX(FBMinCompressionQuality, - MIN(FBMaxCompressionQuality, FBConfiguration.mjpegServerScreenshotQuality / 100.0)); + MIN(FBMaxCompressionQuality, (double)FBConfiguration.mjpegServerScreenshotQuality / 100.0)); NSData *screenshotData = [FBScreenshot takeInOriginalResolutionWithScreenID:self.mainScreenID compressionQuality:compressionQuality uti:UTTypeJPEG @@ -106,23 +125,41 @@ - (void)streamScreenshot self.consecutiveScreenshotFailures = 0; CGFloat scalingFactor = FBConfiguration.mjpegScalingFactor / 100.0; + __weak typeof(self) weakSelf = self; [self.imageProcessor submitImageData:screenshotData scalingFactor:scalingFactor completionHandler:^(NSData * _Nonnull scaled) { - [self sendScreenshot:scaled]; + [weakSelf sendScreenshot:scaled]; }]; [self scheduleNextScreenshotWithInterval:timerInterval timeStarted:timeStarted]; } - (void)sendScreenshot:(NSData *)screenshotData { + if (!self.isStreaming) { + return; + } NSString *chunkHeader = [NSString stringWithFormat:@"--BoundaryString\r\nContent-type: image/jpeg\r\nContent-Length: %@\r\n\r\n", @(screenshotData.length)]; NSMutableData *chunk = [[chunkHeader dataUsingEncoding:NSUTF8StringEncoding] mutableCopy]; [chunk appendData:screenshotData]; [chunk appendData:(id)[@"\r\n\r\n" dataUsingEncoding:NSUTF8StringEncoding]]; @synchronized (self.listeningClients) { + if (!self.isStreaming || 0 == self.listeningClients.count) { + return; + } + NSUInteger clientCount = self.listeningClients.count; for (GCDAsyncSocket *client in self.listeningClients) { - [client writeData:chunk withTimeout:-1 tag:0]; + // Slow clients should fail/close instead of buffering indefinitely. + [client writeData:chunk withTimeout:FRAME_TIMEOUT tag:0]; + } + self.sentFramesCount++; + self.sentBytesCount += chunk.length * clientCount; + NSUInteger framerate = FBNormalizedMjpegFramerate(FBConfiguration.mjpegServerFramerate); + if (0 == self.sentFramesCount % framerate) { + [FBLogger verboseLog:[NSString stringWithFormat:@"MJPEG stats: clients=%@ sentFrames=%@ sentBytes=%@", + @(clientCount), + @(self.sentFramesCount), + @(self.sentBytesCount)]]; } } } @@ -158,4 +195,22 @@ - (void)didClientDisconnect:(GCDAsyncSocket *)client [FBLogger log:@"Disconnected a client from screenshots broadcast"]; } +- (void)stopStreaming +{ + self.isStreaming = NO; + @synchronized (self.listeningClients) { + NSArray *clients = self.listeningClients.copy; + [self.listeningClients removeAllObjects]; + for (GCDAsyncSocket *client in clients) { + [client disconnect]; + } + } +} + +- (void)dealloc +{ + [self stopStreaming]; + [FBLogger verboseLog:@"FBMjpegServer deallocated"]; +} + @end diff --git a/WebDriverAgentLib/Utilities/FBScreenshot.m b/WebDriverAgentLib/Utilities/FBScreenshot.m index f13f96738..618fa308f 100644 --- a/WebDriverAgentLib/Utilities/FBScreenshot.m +++ b/WebDriverAgentLib/Utilities/FBScreenshot.m @@ -126,7 +126,7 @@ + (NSData *)takeInOriginalResolutionWithScreenID:(long long)screenID withDescription:timeoutMsg] buildError:error]; } - }; + } if (nil != error && nil != innerError) { *error = innerError; } diff --git a/WebDriverAgentLib/Utilities/FBSettings.h b/WebDriverAgentLib/Utilities/FBSettings.h index 6fcdde3d9..3a95e0801 100644 --- a/WebDriverAgentLib/Utilities/FBSettings.h +++ b/WebDriverAgentLib/Utilities/FBSettings.h @@ -28,7 +28,6 @@ extern NSString* const FB_SETTING_BOUND_ELEMENTS_BY_INDEX; extern NSString* const FB_SETTING_REDUCE_MOTION; extern NSString* const FB_SETTING_DEFAULT_ACTIVE_APPLICATION; extern NSString* const FB_SETTING_ACTIVE_APP_DETECTION_POINT; -extern NSString* const FB_SETTING_INCLUDE_NON_MODAL_ELEMENTS; extern NSString* const FB_SETTING_DEFAULT_ALERT_ACTION; extern NSString* const FB_SETTING_ACCEPT_ALERT_BUTTON_SELECTOR; extern NSString* const FB_SETTING_DISMISS_ALERT_BUTTON_SELECTOR; @@ -42,6 +41,7 @@ extern NSString* const FB_SETTING_LIMIT_XPATH_CONTEXT_SCOPE; extern NSString* const FB_SETTING_AUTO_CLICK_ALERT_SELECTOR; extern NSString *const FB_SETTING_INCLUDE_HITTABLE_IN_PAGE_SOURCE; extern NSString *const FB_SETTING_INCLUDE_NATIVE_FRAME_IN_PAGE_SOURCE; +extern NSString *const FB_SETTING_INCLUDE_NATIVE_ACCESSIBILITY_ELEMENT_IN_PAGE_SOURCE; extern NSString *const FB_SETTING_INCLUDE_MIN_MAX_VALUE_IN_PAGE_SOURCE; extern NSString *const FB_SETTING_INCLUDE_CUSTOM_ACTIONS_IN_PAGE_SOURCE; extern NSString *const FB_SETTING_PRE_WARM_PAGE_SOURCE; diff --git a/WebDriverAgentLib/Utilities/FBSettings.m b/WebDriverAgentLib/Utilities/FBSettings.m index 3ef64d956..06c9a683b 100644 --- a/WebDriverAgentLib/Utilities/FBSettings.m +++ b/WebDriverAgentLib/Utilities/FBSettings.m @@ -24,7 +24,6 @@ NSString* const FB_SETTING_REDUCE_MOTION = @"reduceMotion"; NSString* const FB_SETTING_DEFAULT_ACTIVE_APPLICATION = @"defaultActiveApplication"; NSString* const FB_SETTING_ACTIVE_APP_DETECTION_POINT = @"activeAppDetectionPoint"; -NSString* const FB_SETTING_INCLUDE_NON_MODAL_ELEMENTS = @"includeNonModalElements"; NSString* const FB_SETTING_DEFAULT_ALERT_ACTION = @"defaultAlertAction"; NSString* const FB_SETTING_ACCEPT_ALERT_BUTTON_SELECTOR = @"acceptAlertButtonSelector"; NSString* const FB_SETTING_DISMISS_ALERT_BUTTON_SELECTOR = @"dismissAlertButtonSelector"; @@ -38,6 +37,7 @@ NSString* const FB_SETTING_AUTO_CLICK_ALERT_SELECTOR = @"autoClickAlertSelector"; NSString* const FB_SETTING_INCLUDE_HITTABLE_IN_PAGE_SOURCE = @"includeHittableInPageSource"; NSString* const FB_SETTING_INCLUDE_NATIVE_FRAME_IN_PAGE_SOURCE = @"includeNativeFrameInPageSource"; +NSString* const FB_SETTING_INCLUDE_NATIVE_ACCESSIBILITY_ELEMENT_IN_PAGE_SOURCE = @"includeNativeAccessibilityElementInPageSource"; NSString* const FB_SETTING_INCLUDE_MIN_MAX_VALUE_IN_PAGE_SOURCE = @"includeMinMaxValueInPageSource"; NSString* const FB_SETTING_INCLUDE_CUSTOM_ACTIONS_IN_PAGE_SOURCE = @"includeCustomActionsInPageSource"; NSString* const FB_SETTING_PRE_WARM_PAGE_SOURCE = @"preWarmPageSource"; diff --git a/WebDriverAgentLib/Utilities/FBSettingsHandler.h b/WebDriverAgentLib/Utilities/FBSettingsHandler.h new file mode 100644 index 000000000..bb30384d7 --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSettingsHandler.h @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@class FBCommandStatus; +@class FBSession; + +NS_ASSUME_NONNULL_BEGIN + +@interface FBSettingsHandler : NSObject + +/** + * Applies the given settings dictionary to FBConfiguration and the active session. + * JSON null values are normalized to nil. Nil is applied only for settings that + * support clearing (e.g. alert action and selectors); other keys are skipped so + * null does not get coerced to NO/0. Unknown keys are skipped. + * + * @return nil on success, or an FBCommandStatus describing the validation error. + */ ++ (nullable FBCommandStatus *)applySettings:(NSDictionary *)settings toSession:(FBSession *)session; + +/** + * Returns the current values for all known settings. + */ ++ (NSDictionary *)currentSettingsForSession:(FBSession *)session; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBSettingsHandler.m b/WebDriverAgentLib/Utilities/FBSettingsHandler.m new file mode 100644 index 000000000..6786d881a --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSettingsHandler.m @@ -0,0 +1,370 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBSettingsHandler.h" + +#import "FBActiveAppDetectionPoint.h" +#import "FBClassChainQueryParser.h" +#import "FBCommandStatus.h" +#import "FBConfiguration.h" +#import "FBSession.h" +#import "FBSettings.h" + +typedef FBCommandStatus * _Nullable (^FBSettingApplyBlock)(FBSession *session, id value); +typedef id _Nonnull (^FBSettingGetBlock)(FBSession *session); + +static id FBNormalizedSettingValue(id value) +{ + return value == NSNull.null ? nil : value; +} + +static NSSet *FBNilClearableSettingKeys(void) +{ + static NSSet *keys; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + keys = [NSSet setWithObjects: + FB_SETTING_DEFAULT_ALERT_ACTION, + FB_SETTING_ACCEPT_ALERT_BUTTON_SELECTOR, + FB_SETTING_DISMISS_ALERT_BUTTON_SELECTOR, + FB_SETTING_AUTO_CLICK_ALERT_SELECTOR, + nil]; + }); + return keys; +} + +@implementation FBSettingsHandler + ++ (NSDictionary *)settersMap +{ + static NSDictionary *settersMap; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSMutableDictionary *map = [NSMutableDictionary dictionary]; + map[FB_SETTING_USE_COMPACT_RESPONSES] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setShouldUseCompactResponses:[value boolValue]]; + return nil; + }; + map[FB_SETTING_ELEMENT_RESPONSE_ATTRIBUTES] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setElementResponseAttributes:(NSString *)value]; + return nil; + }; + map[FB_SETTING_MJPEG_SERVER_SCREENSHOT_QUALITY] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setMjpegServerScreenshotQuality:[value unsignedIntegerValue]]; + return nil; + }; + map[FB_SETTING_MJPEG_SERVER_FRAMERATE] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setMjpegServerFramerate:[value unsignedIntegerValue]]; + return nil; + }; + map[FB_SETTING_SCREENSHOT_QUALITY] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setScreenshotQuality:[value unsignedIntegerValue]]; + return nil; + }; + map[FB_SETTING_MJPEG_SCALING_FACTOR] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setMjpegScalingFactor:[value floatValue]]; + return nil; + }; + map[FB_SETTING_MJPEG_FIX_ORIENTATION] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setMjpegShouldFixOrientation:[value boolValue]]; + return nil; + }; + map[FB_SETTING_KEYBOARD_AUTOCORRECTION] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setKeyboardAutocorrection:[value boolValue]]; + return nil; + }; + map[FB_SETTING_KEYBOARD_PREDICTION] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setKeyboardPrediction:[value boolValue]]; + return nil; + }; + map[FB_SETTING_RESPECT_SYSTEM_ALERTS] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setShouldRespectSystemAlerts:[value boolValue]]; + return nil; + }; + map[FB_SETTING_SNAPSHOT_MAX_DEPTH] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setSnapshotMaxDepth:[value intValue]]; + return nil; + }; + map[FB_SETTING_SNAPSHOT_MAX_CHILDREN] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setSnapshotMaxChildren:[value intValue]]; + return nil; + }; + map[FB_SETTING_USE_FIRST_MATCH] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setUseFirstMatch:[value boolValue]]; + return nil; + }; + map[FB_SETTING_BOUND_ELEMENTS_BY_INDEX] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setBoundElementsByIndex:[value boolValue]]; + return nil; + }; + map[FB_SETTING_REDUCE_MOTION] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setReduceMotionEnabled:[value boolValue]]; + return nil; + }; + map[FB_SETTING_DEFAULT_ACTIVE_APPLICATION] = ^FBCommandStatus *(FBSession *session, id value) { + session.defaultActiveApplication = (NSString *)value; + return nil; + }; + map[FB_SETTING_ACTIVE_APP_DETECTION_POINT] = ^FBCommandStatus *(FBSession *session, id value) { + NSError *error; + if (![FBActiveAppDetectionPoint.sharedInstance setCoordinatesWithString:(NSString *)value + error:&error]) { + return [FBCommandStatus invalidArgumentErrorWithMessage:error.localizedDescription + traceback:nil]; + } + return nil; + }; + map[FB_SETTING_ACCEPT_ALERT_BUTTON_SELECTOR] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setAcceptAlertButtonSelector:(NSString *)value]; + return nil; + }; + map[FB_SETTING_DISMISS_ALERT_BUTTON_SELECTOR] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setDismissAlertButtonSelector:(NSString *)value]; + return nil; + }; + map[FB_SETTING_AUTO_CLICK_ALERT_SELECTOR] = ^FBCommandStatus *(FBSession *session, id value) { + return [self configureAutoClickAlertWithSelector:(NSString *)value forSession:session]; + }; + map[FB_SETTING_WAIT_FOR_IDLE_TIMEOUT] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setWaitForIdleTimeout:[value doubleValue]]; + return nil; + }; + map[FB_SETTING_ANIMATION_COOL_OFF_TIMEOUT] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setAnimationCoolOffTimeout:[value doubleValue]]; + return nil; + }; + map[FB_SETTING_DEFAULT_ALERT_ACTION] = ^FBCommandStatus *(FBSession *session, id value) { + if (nil == value) { + session.defaultAlertAction = nil; + } else if ([value isKindOfClass:NSString.class]) { + session.defaultAlertAction = [(NSString *)value lowercaseString]; + } + return nil; + }; + map[FB_SETTING_MAX_TYPING_FREQUENCY] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setMaxTypingFrequency:[value unsignedIntegerValue]]; + return nil; + }; + map[FB_SETTING_USE_CLEAR_TEXT_SHORTCUT] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setUseClearTextShortcut:[value boolValue]]; + return nil; + }; + map[FB_SETTING_INCLUDE_HITTABLE_IN_PAGE_SOURCE] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setIncludeHittableInPageSource:[value boolValue]]; + return nil; + }; + map[FB_SETTING_INCLUDE_NATIVE_FRAME_IN_PAGE_SOURCE] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setIncludeNativeFrameInPageSource:[value boolValue]]; + return nil; + }; + map[FB_SETTING_INCLUDE_NATIVE_ACCESSIBILITY_ELEMENT_IN_PAGE_SOURCE] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setIncludeNativeAccessibilityElementInPageSource:[value boolValue]]; + return nil; + }; + map[FB_SETTING_INCLUDE_MIN_MAX_VALUE_IN_PAGE_SOURCE] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setIncludeMinMaxValueInPageSource:[value boolValue]]; + return nil; + }; + map[FB_SETTING_INCLUDE_CUSTOM_ACTIONS_IN_PAGE_SOURCE] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setIncludeCustomActionsInPageSource:[value boolValue]]; + return nil; + }; + map[FB_SETTING_PRE_WARM_PAGE_SOURCE] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setPreWarmPageSource:[value boolValue]]; + return nil; + }; + map[FB_SETTING_ENFORCE_CUSTOM_SNAPSHOTS] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setEnforceCustomSnapshots:[value boolValue]]; + return nil; + }; + map[FB_SETTING_LIMIT_XPATH_CONTEXT_SCOPE] = ^FBCommandStatus *(FBSession *session, id value) { + [FBConfiguration setLimitXpathContextScope:[value boolValue]]; + return nil; + }; +#if !TARGET_OS_TV + map[FB_SETTING_SCREENSHOT_ORIENTATION] = ^FBCommandStatus *(FBSession *session, id value) { + NSError *error; + if (![FBConfiguration setScreenshotOrientation:(NSString *)value error:&error]) { + return [FBCommandStatus invalidArgumentErrorWithMessage:error.localizedDescription + traceback:nil]; + } + return nil; + }; +#endif + settersMap = map.copy; + }); + return settersMap; +} + ++ (NSDictionary *)gettersMap +{ + static NSDictionary *gettersMap; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSMutableDictionary *map = [NSMutableDictionary dictionary]; + map[FB_SETTING_USE_COMPACT_RESPONSES] = ^id(FBSession *session) { + return @([FBConfiguration shouldUseCompactResponses]); + }; + map[FB_SETTING_ELEMENT_RESPONSE_ATTRIBUTES] = ^id(FBSession *session) { + return [FBConfiguration elementResponseAttributes]; + }; + map[FB_SETTING_MJPEG_SERVER_SCREENSHOT_QUALITY] = ^id(FBSession *session) { + return @([FBConfiguration mjpegServerScreenshotQuality]); + }; + map[FB_SETTING_MJPEG_SERVER_FRAMERATE] = ^id(FBSession *session) { + return @([FBConfiguration mjpegServerFramerate]); + }; + map[FB_SETTING_MJPEG_SCALING_FACTOR] = ^id(FBSession *session) { + return @([FBConfiguration mjpegScalingFactor]); + }; + map[FB_SETTING_MJPEG_FIX_ORIENTATION] = ^id(FBSession *session) { + return @([FBConfiguration mjpegShouldFixOrientation]); + }; + map[FB_SETTING_SCREENSHOT_QUALITY] = ^id(FBSession *session) { + return @([FBConfiguration screenshotQuality]); + }; + map[FB_SETTING_KEYBOARD_AUTOCORRECTION] = ^id(FBSession *session) { + return @([FBConfiguration keyboardAutocorrection]); + }; + map[FB_SETTING_KEYBOARD_PREDICTION] = ^id(FBSession *session) { + return @([FBConfiguration keyboardPrediction]); + }; + map[FB_SETTING_SNAPSHOT_MAX_DEPTH] = ^id(FBSession *session) { + return @([FBConfiguration snapshotMaxDepth]); + }; + map[FB_SETTING_SNAPSHOT_MAX_CHILDREN] = ^id(FBSession *session) { + return @([FBConfiguration snapshotMaxChildren]); + }; + map[FB_SETTING_USE_FIRST_MATCH] = ^id(FBSession *session) { + return @([FBConfiguration useFirstMatch]); + }; + map[FB_SETTING_WAIT_FOR_IDLE_TIMEOUT] = ^id(FBSession *session) { + return @([FBConfiguration waitForIdleTimeout]); + }; + map[FB_SETTING_ANIMATION_COOL_OFF_TIMEOUT] = ^id(FBSession *session) { + return @([FBConfiguration animationCoolOffTimeout]); + }; + map[FB_SETTING_BOUND_ELEMENTS_BY_INDEX] = ^id(FBSession *session) { + return @([FBConfiguration boundElementsByIndex]); + }; + map[FB_SETTING_REDUCE_MOTION] = ^id(FBSession *session) { + return @([FBConfiguration reduceMotionEnabled]); + }; + map[FB_SETTING_DEFAULT_ACTIVE_APPLICATION] = ^id(FBSession *session) { + return session.defaultActiveApplication; + }; + map[FB_SETTING_ACTIVE_APP_DETECTION_POINT] = ^id(FBSession *session) { + return FBActiveAppDetectionPoint.sharedInstance.stringCoordinates; + }; + map[FB_SETTING_ACCEPT_ALERT_BUTTON_SELECTOR] = ^id(FBSession *session) { + return FBConfiguration.acceptAlertButtonSelector; + }; + map[FB_SETTING_DISMISS_ALERT_BUTTON_SELECTOR] = ^id(FBSession *session) { + return FBConfiguration.dismissAlertButtonSelector; + }; + map[FB_SETTING_AUTO_CLICK_ALERT_SELECTOR] = ^id(FBSession *session) { + return FBConfiguration.autoClickAlertSelector; + }; + map[FB_SETTING_DEFAULT_ALERT_ACTION] = ^id(FBSession *session) { + return session.defaultAlertAction ?: @""; + }; + map[FB_SETTING_MAX_TYPING_FREQUENCY] = ^id(FBSession *session) { + return @([FBConfiguration maxTypingFrequency]); + }; + map[FB_SETTING_RESPECT_SYSTEM_ALERTS] = ^id(FBSession *session) { + return @([FBConfiguration shouldRespectSystemAlerts]); + }; + map[FB_SETTING_USE_CLEAR_TEXT_SHORTCUT] = ^id(FBSession *session) { + return @([FBConfiguration useClearTextShortcut]); + }; + map[FB_SETTING_INCLUDE_HITTABLE_IN_PAGE_SOURCE] = ^id(FBSession *session) { + return @([FBConfiguration includeHittableInPageSource]); + }; + map[FB_SETTING_INCLUDE_NATIVE_FRAME_IN_PAGE_SOURCE] = ^id(FBSession *session) { + return @([FBConfiguration includeNativeFrameInPageSource]); + }; + map[FB_SETTING_INCLUDE_NATIVE_ACCESSIBILITY_ELEMENT_IN_PAGE_SOURCE] = ^id(FBSession *session) { + return @([FBConfiguration includeNativeAccessibilityElementInPageSource]); + }; + map[FB_SETTING_INCLUDE_MIN_MAX_VALUE_IN_PAGE_SOURCE] = ^id(FBSession *session) { + return @([FBConfiguration includeMinMaxValueInPageSource]); + }; + map[FB_SETTING_INCLUDE_CUSTOM_ACTIONS_IN_PAGE_SOURCE] = ^id(FBSession *session) { + return @([FBConfiguration includeCustomActionsInPageSource]); + }; + map[FB_SETTING_PRE_WARM_PAGE_SOURCE] = ^id(FBSession *session) { + return @([FBConfiguration preWarmPageSource]); + }; + map[FB_SETTING_ENFORCE_CUSTOM_SNAPSHOTS] = ^id(FBSession *session) { + return @([FBConfiguration enforceCustomSnapshots]); + }; + map[FB_SETTING_LIMIT_XPATH_CONTEXT_SCOPE] = ^id(FBSession *session) { + return @([FBConfiguration limitXpathContextScope]); + }; +#if !TARGET_OS_TV + map[FB_SETTING_SCREENSHOT_ORIENTATION] = ^id(FBSession *session) { + return [FBConfiguration humanReadableScreenshotOrientation]; + }; +#endif + gettersMap = map.copy; + }); + return gettersMap; +} + ++ (NSDictionary *)currentSettingsForSession:(FBSession *)session +{ + NSDictionary *gettersMap = [self gettersMap]; + NSMutableDictionary *settings = [NSMutableDictionary dictionaryWithCapacity:gettersMap.count]; + for (NSString *key in gettersMap) { + settings[key] = gettersMap[key](session); + } + return settings.copy; +} + ++ (nullable FBCommandStatus *)applySettings:(NSDictionary *)settings toSession:(FBSession *)session +{ + NSDictionary *settersMap = [self settersMap]; + NSSet *nilClearableKeys = FBNilClearableSettingKeys(); + for (NSString *key in settings) { + FBSettingApplyBlock handler = settersMap[key]; + if (nil == handler) { + continue; + } + id value = FBNormalizedSettingValue(settings[key]); + if (nil == value && ![nilClearableKeys containsObject:key]) { + continue; + } + FBCommandStatus *status = handler(session, value); + if (status.hasError) { + return status; + } + } + return nil; +} + ++ (FBCommandStatus *)configureAutoClickAlertWithSelector:(NSString *)selector + forSession:(FBSession *)session +{ + if (0 == [selector length]) { + [FBConfiguration setAutoClickAlertSelector:selector]; + [session disableAlertsMonitor]; + return [FBCommandStatus ok]; + } + + NSError *error; + FBClassChain *parsedChain = [FBClassChainQueryParser parseQuery:selector error:&error]; + if (nil == parsedChain) { + return [FBCommandStatus invalidSelectorErrorWithMessage:error.localizedDescription + traceback:nil]; + } + [FBConfiguration setAutoClickAlertSelector:selector]; + [session enableAlertsMonitor]; + return [FBCommandStatus ok]; +} + +@end diff --git a/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m b/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m index e29b94e99..e1ced2be1 100644 --- a/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m +++ b/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m @@ -134,7 +134,7 @@ + (BOOL)openURL:(NSURL *)url usingApplication:(NSString *)bundleId error:(NSErro XCTRunnerDaemonSession *session = [XCTRunnerDaemonSession sharedSession]; if (![session respondsToSelector:@selector(openURL:usingApplication:completion:)]) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"The current Xcode SDK does not support opening of URLs with given application"] + withDescriptionFormat:@"The current OS runtime does not support opening URLs with a given application"] buildError:error]; } @@ -161,7 +161,7 @@ + (BOOL)openDefaultApplicationForURL:(NSURL *)url error:(NSError *__autoreleasin XCTRunnerDaemonSession *session = [XCTRunnerDaemonSession sharedSession]; if (![session respondsToSelector:@selector(openDefaultApplicationForURL:completion:)]) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"The current Xcode SDK does not support opening of URLs. Consider upgrading to Xcode 14.3+/iOS 16.4+"] + withDescriptionFormat:@"The current OS runtime does not support opening URLs. This API requires an iOS 16.4+ runtime"] buildError:error]; } @@ -189,7 +189,7 @@ + (BOOL)setSimulatedLocation:(CLLocation *)location error:(NSError *__autoreleas XCTRunnerDaemonSession *session = [XCTRunnerDaemonSession sharedSession]; if (![session respondsToSelector:@selector(setSimulatedLocation:completion:)]) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"The current Xcode SDK does not support location simulation. Consider upgrading to Xcode 14.3+/iOS 16.4+"] + withDescriptionFormat:@"The current OS runtime does not support location simulation. This API requires an iOS 16.4+ runtime"] buildError:error]; } if (![session supportsLocationSimulation]) { @@ -221,7 +221,7 @@ + (nullable CLLocation *)getSimulatedLocation:(NSError *__autoreleasing*)error; XCTRunnerDaemonSession *session = [XCTRunnerDaemonSession sharedSession]; if (![session respondsToSelector:@selector(getSimulatedLocationWithReply:)]) { [[[FBErrorBuilder builder] - withDescriptionFormat:@"The current Xcode SDK does not support location simulation. Consider upgrading to Xcode 14.3+/iOS 16.4+"] + withDescriptionFormat:@"The current OS runtime does not support location simulation. This API requires an iOS 16.4+ runtime"] buildError:error]; return nil; } @@ -255,7 +255,7 @@ + (BOOL)clearSimulatedLocation:(NSError *__autoreleasing*)error XCTRunnerDaemonSession *session = [XCTRunnerDaemonSession sharedSession]; if (![session respondsToSelector:@selector(clearSimulatedLocationWithReply:)]) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"The current Xcode SDK does not support location simulation. Consider upgrading to Xcode 14.3+/iOS 16.4+"] + withDescriptionFormat:@"The current OS runtime does not support location simulation. This API requires an iOS 16.4+ runtime"] buildError:error]; } if (![session supportsLocationSimulation]) { @@ -289,7 +289,7 @@ + (FBScreenRecordingPromise *)startScreenRecordingWithRequest:(FBScreenRecording XCTRunnerDaemonSession *session = [XCTRunnerDaemonSession sharedSession]; if (![session respondsToSelector:@selector(startScreenRecordingWithRequest:withReply:)]) { [[[FBErrorBuilder builder] - withDescriptionFormat:@"The current Xcode SDK does not support screen recording. Consider upgrading to Xcode 15+/iOS 17+"] + withDescriptionFormat:@"The current OS runtime does not support screen recording. This API requires an iOS 17+ runtime"] buildError:error]; return nil; } @@ -331,7 +331,7 @@ + (BOOL)stopScreenRecordingWithUUID:(NSUUID *)uuid error:(NSError *__autoreleasi XCTRunnerDaemonSession *session = [XCTRunnerDaemonSession sharedSession]; if (![session respondsToSelector:@selector(stopScreenRecordingWithUUID:withReply:)]) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"The current Xcode SDK does not support screen recording. Consider upgrading to Xcode 15+/iOS 17+"] + withDescriptionFormat:@"The current OS runtime does not support screen recording. This API requires an iOS 17+ runtime"] buildError:error]; } diff --git a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.h b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.h index c105726a0..85ce1d091 100644 --- a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.h +++ b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.h @@ -61,17 +61,10 @@ NS_ASSUME_NONNULL_BEGIN @interface XCUIElement (FBCompatibility) -/** - Determines whether current iOS SDK supports non modal elements inlusion into snapshots - - @return Either YES or NO - */ -+ (BOOL)fb_supportsNonModalElementsInclusion; - /** Retrieves element query - @return Element query property extended with non modal elements depending on the actual configuration + @return Element query */ - (XCUIElementQuery *)fb_query; diff --git a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m index f2cf03a24..5f88ccb56 100644 --- a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m +++ b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m @@ -45,21 +45,9 @@ - (XCUIElement *)fb_firstMatch @implementation XCUIElement (FBCompatibility) -+ (BOOL)fb_supportsNonModalElementsInclusion -{ - static dispatch_once_t hasIncludingNonModalElements; - static BOOL result; - dispatch_once(&hasIncludingNonModalElements, ^{ - result = [XCUIApplication.fb_systemApplication.query respondsToSelector:@selector(includingNonModalElements)]; - }); - return result; -} - - (XCUIElementQuery *)fb_query { - return FBConfiguration.includeNonModalElements && self.class.fb_supportsNonModalElementsInclusion - ? self.query.includingNonModalElements - : self.query; + return self.query; } @end diff --git a/WebDriverAgentLib/Utilities/FBXPath-Private.h b/WebDriverAgentLib/Utilities/FBXPath-Private.h index db9732c41..fa4b16265 100644 --- a/WebDriverAgentLib/Utilities/FBXPath-Private.h +++ b/WebDriverAgentLib/Utilities/FBXPath-Private.h @@ -51,6 +51,11 @@ NS_ASSUME_NONNULL_BEGIN document:(xmlDocPtr)doc contextNode:(nullable xmlNodePtr)contextNode; ++ (xmlXPathObjectPtr)evaluate:(NSString *)xpathQuery + document:(xmlDocPtr)doc + contextNode:(nullable xmlNodePtr)contextNode + errorMessage:(NSString * _Nullable * _Nullable)errorMessage; + @end NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBXPath.h b/WebDriverAgentLib/Utilities/FBXPath.h index 640fd1dd2..6bfe6bd04 100644 --- a/WebDriverAgentLib/Utilities/FBXPath.h +++ b/WebDriverAgentLib/Utilities/FBXPath.h @@ -183,6 +183,10 @@ NS_ASSUME_NONNULL_BEGIN @end +@interface FBNativeAccessibilityElementAttribute : FBElementAttribute + +@end + @interface FBTraitsAttribute : FBElementAttribute @end diff --git a/WebDriverAgentLib/Utilities/FBXPath.m b/WebDriverAgentLib/Utilities/FBXPath.m index dddcc88c6..8acedae76 100644 --- a/WebDriverAgentLib/Utilities/FBXPath.m +++ b/WebDriverAgentLib/Utilities/FBXPath.m @@ -14,6 +14,7 @@ #import "FBLogger.h" #import "FBMacros.h" #import "FBXMLGenerationOptions.h" +#import "FBXPathExtensions.h" #import "FBXCElementSnapshotWrapper+Helpers.h" #import "NSString+FBXMLSafeString.h" #import "XCUIApplication.h" @@ -35,7 +36,17 @@ @implementation FBXPath + (id)throwException:(NSString *)name forQuery:(NSString *)xpathQuery { - NSString *reason = [NSString stringWithFormat:@"Cannot evaluate results for XPath expression \"%@\"", xpathQuery]; + return [self throwException:name forQuery:xpathQuery detail:nil]; +} + ++ (id)throwException:(NSString *)name forQuery:(NSString *)xpathQuery detail:(nullable NSString *)detail +{ + NSString *reason; + if (nil != detail) { + reason = [NSString stringWithFormat:@"Cannot evaluate results for XPath expression \"%@\": %@", xpathQuery, detail]; + } else { + reason = [NSString stringWithFormat:@"Cannot evaluate results for XPath expression \"%@\"", xpathQuery]; + } @throw [NSException exceptionWithName:name reason:reason userInfo:@{}]; return nil; } @@ -214,16 +225,18 @@ + (nullable NSString *)xmlStringWithRootElement:(id)root contextNode = nodeSet->nodeTab[0]; } } + NSString *evaluationError = nil; xmlXPathObjectPtr queryResult = [self evaluate:xpathQuery document:doc - contextNode:contextNode]; + contextNode:contextNode + errorMessage:&evaluationError]; if (NULL != contextNodeQueryResult) { xmlXPathFreeObject(contextNodeQueryResult); } if (NULL == queryResult) { xmlFreeTextWriter(writer); xmlFreeDoc(doc); - return [self throwException:FBInvalidXPathException forQuery:xpathQuery]; + return [self throwException:FBInvalidXPathException forQuery:xpathQuery detail:evaluationError]; } NSArray *matchingSnapshots = [self collectMatchingSnapshots:queryResult->nodesetval @@ -326,6 +339,10 @@ + (int)xmlRepresentationWithRootElement:(id)root // Include nativeFrame only when requested [includedAttributes removeObject:FBNativeFrameAttribute.class]; } + if (!FBConfiguration.includeNativeAccessibilityElementInPageSource) { + // Include the raw native accessibility flag only when requested + [includedAttributes removeObject:FBNativeAccessibilityElementAttribute.class]; + } if (!FBConfiguration.includeMinMaxValueInPageSource) { // minValue/maxValue are retrieved from private APIs and may be slow on deep trees [includedAttributes removeObject:FBMinValueAttribute.class]; @@ -365,6 +382,14 @@ + (int)xmlRepresentationWithRootElement:(id)root + (xmlXPathObjectPtr)evaluate:(NSString *)xpathQuery document:(xmlDocPtr)doc contextNode:(nullable xmlNodePtr)contextNode +{ + return [self evaluate:xpathQuery document:doc contextNode:contextNode errorMessage:nil]; +} + ++ (xmlXPathObjectPtr)evaluate:(NSString *)xpathQuery + document:(xmlDocPtr)doc + contextNode:(nullable xmlNodePtr)contextNode + errorMessage:(NSString * _Nullable * _Nullable)errorMessage { xmlXPathContextPtr xpathCtx = xmlXPathNewContext(doc); if (NULL == xpathCtx) { @@ -373,10 +398,21 @@ + (xmlXPathObjectPtr)evaluate:(NSString *)xpathQuery } xpathCtx->node = NULL == contextNode ? doc->children : contextNode; + FBXPathExtensions *extensions = [FBXPathExtensions new]; + [extensions registerFunctionsWithContext:xpathCtx]; + xmlXPathObjectPtr xpathObj = xmlXPathEvalExpression((const xmlChar *)[xpathQuery UTF8String], xpathCtx); if (NULL == xpathObj) { + NSString *detail = extensions.lastEvaluationError; + if (NULL != errorMessage) { + *errorMessage = detail; + } + if (nil != detail) { + [FBLogger logFmt:@"Failed to evaluate XPath query \"%@\": %@", xpathQuery, detail]; + } else { + [FBLogger logFmt:@"Failed to invoke libxml2>xmlXPathEvalExpression for XPath query \"%@\"", xpathQuery]; + } xmlXPathFreeContext(xpathCtx); - [FBLogger logFmt:@"Failed to invoke libxml2>xmlXPathEvalExpression for XPath query \"%@\"", xpathQuery]; return NULL; } xmlXPathFreeContext(xpathCtx); @@ -584,6 +620,7 @@ + (int)recordWithWriter:(xmlTextWriterPtr)writer forValue:(nullable NSString *)v FBEnabledAttribute.class, FBVisibleAttribute.class, FBAccessibleAttribute.class, + FBNativeAccessibilityElementAttribute.class, #if TARGET_OS_TV FBFocusedAttribute.class, #endif @@ -852,6 +889,19 @@ + (NSString *)valueForElement:(id)element } @end +@implementation FBNativeAccessibilityElementAttribute + ++ (NSString *)name +{ + return @"nativeAccessibilityElement"; +} + ++ (NSString *)valueForElement:(id)element +{ + return FBBoolToString(element.wdNativeAccessibilityElement); +} +@end + @implementation FBTraitsAttribute + (NSString *)name diff --git a/WebDriverAgentLib/Utilities/FBXPathExtensions.h b/WebDriverAgentLib/Utilities/FBXPathExtensions.h new file mode 100644 index 000000000..11f95bbda --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBXPathExtensions.h @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#ifdef __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wpadded" +#endif + +#import + +#ifdef __clang__ +#pragma clang diagnostic pop +#endif + +NS_ASSUME_NONNULL_BEGIN + +@interface FBXPathExtensions : NSObject + +/** + Registers XPath 2-compatible extension functions on the given libxml2 context. + */ +- (void)registerFunctionsWithContext:(xmlXPathContextPtr)xpathCtx; + +/** + Human-readable message for the most recent XPath extension evaluation failure on this instance, + for example an invalid regular expression pattern or flags. Nil when no extension error has occurred. + Scoped to the libxml2 context this instance is registered with; each evaluation should use its own instance. + */ +@property (nonatomic, nullable, readonly, copy) NSString *lastEvaluationError; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBXPathExtensions.m b/WebDriverAgentLib/Utilities/FBXPathExtensions.m new file mode 100644 index 000000000..46cc42413 --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBXPathExtensions.m @@ -0,0 +1,418 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBXPathExtensions.h" + +#import "FBLogger.h" + +#import + +static void FBRegisterXPathExtensions(xmlXPathContextPtr xpathCtx); + +static NSString *const FBXPathTokenSequenceSeparator = @"\x1E"; +static const NSRegularExpressionOptions FBXPathNoRegexOptions = (NSRegularExpressionOptions)0; +static const NSMatchingOptions FBXPathNoMatchingOptions = (NSMatchingOptions)0; + +@interface FBXPathExtensions () +@property (nonatomic, nullable, readwrite, copy) NSString *lastEvaluationError; +@end + +static FBXPathExtensions *FBXPathExtensionsFromParserContext(xmlXPathParserContextPtr ctxt) +{ + if (NULL == ctxt || NULL == ctxt->context || NULL == ctxt->context->userData) { + return nil; + } + return (__bridge FBXPathExtensions *)ctxt->context->userData; +} + +static void FBXPathSetEvaluationError(xmlXPathParserContextPtr ctxt, int xpathErrorCode, NSString *message) +{ + FBXPathExtensions *extensions = FBXPathExtensionsFromParserContext(ctxt); + extensions.lastEvaluationError = message; + [FBLogger logFmt:@"XPath extension evaluation error: %@", message]; + if (NULL == ctxt) { + return; + } + xmlXPatherror(ctxt, __FILE__, __LINE__, xpathErrorCode); + ctxt->error = xpathErrorCode; +} + +static void FBXPathSetInvalidArityError(xmlXPathParserContextPtr ctxt) +{ + if (NULL == ctxt) { + return; + } + xmlXPatherror(ctxt, __FILE__, __LINE__, XPATH_INVALID_ARITY); + ctxt->error = XPATH_INVALID_ARITY; +} + +static BOOL FBXPathFlagsAreValid(NSString *flags, BOOL allowsQFlag) +{ + if (nil == flags || 0 == flags.length) { + return YES; + } + + NSString *validFlags = allowsQFlag ? @"imsxq" : @"imsx"; + for (NSUInteger index = 0; index < flags.length; index++) { + unichar flag = [flags characterAtIndex:index]; + if ([validFlags rangeOfString:[NSString stringWithCharacters:&flag length:1]].location == NSNotFound) { + return NO; + } + } + return YES; +} + +static NSString *FBXPathStringFromUTF8Bytes(const xmlChar *bytes) +{ + if (NULL == bytes) { + return nil; + } + return [NSString stringWithUTF8String:(const char *)bytes]; +} + +@implementation FBXPathExtensions + +- (void)registerFunctionsWithContext:(xmlXPathContextPtr)xpathCtx +{ + xpathCtx->userData = (__bridge void *)self; + FBRegisterXPathExtensions(xpathCtx); +} + +@end + +static NSString *FBXPathPopNSString(xmlXPathParserContextPtr ctxt) +{ + xmlChar *value = xmlXPathPopString(ctxt); + if (NULL == value || xmlXPathCheckError(ctxt)) { + return nil; + } + NSString *result = [NSString stringWithUTF8String:(const char *)value]; + xmlFree(value); + return result; +} + +static NSRegularExpressionOptions FBXPathRegexOptionsFromFlags(NSString *flags) +{ + NSRegularExpressionOptions options = FBXPathNoRegexOptions; + if (nil != flags && [flags rangeOfString:@"i"].location != NSNotFound) { + options |= NSRegularExpressionCaseInsensitive; + } + return options; +} + +static NSRegularExpression *FBXPathRegexWithPattern(NSString *pattern, + NSString *flags, + BOOL allowsQFlag, + xmlXPathParserContextPtr ctxt) +{ + if (!FBXPathFlagsAreValid(flags, allowsQFlag)) { + FBXPathSetEvaluationError(ctxt, XPATH_EXPR_ERROR, @"Invalid regular expression flags"); + return nil; + } + + NSError *error = nil; + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern + options:FBXPathRegexOptionsFromFlags(flags) + error:&error]; + if (nil == regex) { + NSString *message = error.localizedDescription ?: @"Invalid regular expression"; + FBXPathSetEvaluationError(ctxt, XPATH_EXPR_ERROR, message); + return nil; + } + return regex; +} + +static BOOL FBXPathTokenizeString(NSString *input, + NSString *pattern, + xmlXPathParserContextPtr ctxt, + NSArray **outTokens) +{ + if (0 == input.length) { + *outTokens = @[]; + return YES; + } + + if (nil == pattern) { + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\S+" + options:FBXPathNoRegexOptions + error:nil]; + if (nil == regex) { + FBXPathSetEvaluationError(ctxt, XPATH_EXPR_ERROR, @"Invalid regular expression"); + return NO; + } + NSMutableArray *tokens = [NSMutableArray array]; + [regex enumerateMatchesInString:input + options:FBXPathNoMatchingOptions + range:NSMakeRange(0, input.length) + usingBlock:^(NSTextCheckingResult *result, NSMatchingFlags flags, BOOL *stop) { + if (nil != result) { + [tokens addObject:[input substringWithRange:result.range]]; + } + }]; + *outTokens = tokens.copy; + return YES; + } + + if (0 == pattern.length) { + NSMutableArray *tokens = [NSMutableArray array]; + [input enumerateSubstringsInRange:NSMakeRange(0, input.length) + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) { + if (substring.length > 0) { + [tokens addObject:substring]; + } + }]; + *outTokens = tokens.copy; + return YES; + } + + NSError *error = nil; + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern + options:FBXPathNoRegexOptions + error:&error]; + if (nil == regex) { + NSString *message = error.localizedDescription ?: @"Invalid regular expression"; + FBXPathSetEvaluationError(ctxt, XPATH_EXPR_ERROR, message); + return NO; + } + + NSMutableArray *tokens = [NSMutableArray array]; + __block NSUInteger lastIndex = 0; + [regex enumerateMatchesInString:input + options:FBXPathNoMatchingOptions + range:NSMakeRange(0, input.length) + usingBlock:^(NSTextCheckingResult *result, NSMatchingFlags flags, BOOL *stop) { + if (nil == result) { + return; + } + if (result.range.location > lastIndex) { + NSString *token = [input substringWithRange:NSMakeRange(lastIndex, result.range.location - lastIndex)]; + if (token.length > 0) { + [tokens addObject:token]; + } + } + lastIndex = NSMaxRange(result.range); + }]; + if (lastIndex < input.length) { + NSString *token = [input substringFromIndex:lastIndex]; + if (token.length > 0) { + [tokens addObject:token]; + } + } + *outTokens = tokens.copy; + return YES; +} + +static void FBXPathReturnNSString(xmlXPathParserContextPtr ctxt, NSString *value) +{ + if (nil == value) { + xmlXPathReturnEmptyString(ctxt); + return; + } + xmlChar *copiedValue = xmlStrdup((const xmlChar *)[value UTF8String]); + if (NULL == copiedValue) { + xmlXPathReturnEmptyString(ctxt); + return; + } + // xmlXPathWrapString takes ownership of the buffer passed to xmlXPathReturnString. + xmlXPathReturnString(ctxt, copiedValue); +} + +static NSArray *FBXPathPartsFromXPathObject(xmlXPathObjectPtr sequence) +{ + if (sequence->type == XPATH_NODESET && NULL != sequence->nodesetval) { + NSMutableArray *parts = [NSMutableArray array]; + for (int index = 0; index < sequence->nodesetval->nodeNr; index++) { + xmlChar *content = xmlNodeGetContent(sequence->nodesetval->nodeTab[index]); + if (NULL != content) { + NSString *part = FBXPathStringFromUTF8Bytes(content); + xmlFree(content); + if (nil != part) { + [parts addObject:part]; + } + } + } + return parts.copy; + } + + xmlChar *asString = xmlXPathCastToString(sequence); + if (NULL == asString) { + return @[]; + } + NSString *value = FBXPathStringFromUTF8Bytes(asString); + xmlFree(asString); + if (nil == value || 0 == value.length) { + return @[]; + } + if ([value rangeOfString:FBXPathTokenSequenceSeparator].location != NSNotFound) { + return [value componentsSeparatedByString:FBXPathTokenSequenceSeparator]; + } + return @[value]; +} + +static void FBXPathMatchesFunction(xmlXPathParserContextPtr ctxt, int nargs) +{ + if (nargs < 2 || nargs > 3) { + FBXPathSetInvalidArityError(ctxt); + return; + } + + NSString *flags = nargs == 3 ? FBXPathPopNSString(ctxt) : nil; + NSString *pattern = FBXPathPopNSString(ctxt); + NSString *input = FBXPathPopNSString(ctxt); + if (nil == pattern || nil == input || xmlXPathCheckError(ctxt)) { + return; + } + + NSRegularExpression *regex = FBXPathRegexWithPattern(pattern, flags, NO, ctxt); + if (nil == regex) { + return; + } + + NSRange range = NSMakeRange(0, input.length); + NSTextCheckingResult *match = [regex firstMatchInString:input options:FBXPathNoMatchingOptions range:range]; + xmlXPathReturnBoolean(ctxt, nil != match); +} + +static void FBXPathEndsWithFunction(xmlXPathParserContextPtr ctxt, int nargs) +{ + if (nargs != 2) { + FBXPathSetInvalidArityError(ctxt); + return; + } + + NSString *suffix = FBXPathPopNSString(ctxt); + NSString *input = FBXPathPopNSString(ctxt); + if (nil == suffix || nil == input || xmlXPathCheckError(ctxt)) { + return; + } + + xmlXPathReturnBoolean(ctxt, [input hasSuffix:suffix]); +} + +static void FBXPathLowerCaseFunction(xmlXPathParserContextPtr ctxt, int nargs) +{ + if (nargs != 1) { + FBXPathSetInvalidArityError(ctxt); + return; + } + + NSString *input = FBXPathPopNSString(ctxt); + if (nil == input || xmlXPathCheckError(ctxt)) { + return; + } + + FBXPathReturnNSString(ctxt, input.lowercaseString); +} + +static void FBXPathUpperCaseFunction(xmlXPathParserContextPtr ctxt, int nargs) +{ + if (nargs != 1) { + FBXPathSetInvalidArityError(ctxt); + return; + } + + NSString *input = FBXPathPopNSString(ctxt); + if (nil == input || xmlXPathCheckError(ctxt)) { + return; + } + + FBXPathReturnNSString(ctxt, input.uppercaseString); +} + +static void FBXPathReplaceFunction(xmlXPathParserContextPtr ctxt, int nargs) +{ + if (nargs < 3 || nargs > 4) { + FBXPathSetInvalidArityError(ctxt); + return; + } + + NSString *flags = nargs == 4 ? FBXPathPopNSString(ctxt) : nil; + NSString *replacement = FBXPathPopNSString(ctxt); + NSString *pattern = FBXPathPopNSString(ctxt); + NSString *input = FBXPathPopNSString(ctxt); + if (nil == replacement || nil == pattern || nil == input || xmlXPathCheckError(ctxt)) { + return; + } + + NSRegularExpression *regex = FBXPathRegexWithPattern(pattern, flags, YES, ctxt); + if (nil == regex) { + return; + } + + NSRange range = NSMakeRange(0, input.length); + NSString *result = [regex stringByReplacingMatchesInString:input + options:FBXPathNoMatchingOptions + range:range + withTemplate:replacement]; + FBXPathReturnNSString(ctxt, result); +} + +static void FBXPathTokenizeFunction(xmlXPathParserContextPtr ctxt, int nargs) +{ + if (nargs < 1 || nargs > 2) { + FBXPathSetInvalidArityError(ctxt); + return; + } + + NSString *pattern = nargs == 2 ? FBXPathPopNSString(ctxt) : nil; + NSString *input = FBXPathPopNSString(ctxt); + if (nil == input || xmlXPathCheckError(ctxt)) { + return; + } + + NSArray *tokens = nil; + if (!FBXPathTokenizeString(input, pattern, ctxt, &tokens)) { + return; + } + + FBXPathReturnNSString(ctxt, [tokens componentsJoinedByString:FBXPathTokenSequenceSeparator]); +} + +static void FBXPathStringJoinFunction(xmlXPathParserContextPtr ctxt, int nargs) +{ + if (nargs != 2) { + FBXPathSetInvalidArityError(ctxt); + return; + } + + xmlChar *separatorChars = xmlXPathPopString(ctxt); + xmlXPathObjectPtr sequence = valuePop(ctxt); + if (xmlXPathCheckError(ctxt) || NULL == sequence || NULL == separatorChars) { + if (NULL != separatorChars) { + xmlFree(separatorChars); + } + if (NULL != sequence) { + xmlXPathFreeObject(sequence); + } + return; + } + + NSString *separator = FBXPathStringFromUTF8Bytes(separatorChars); + xmlFree(separatorChars); + if (nil == separator) { + xmlXPathFreeObject(sequence); + return; + } + + NSArray *parts = FBXPathPartsFromXPathObject(sequence); + xmlXPathFreeObject(sequence); + + FBXPathReturnNSString(ctxt, [parts componentsJoinedByString:separator]); +} + +static void FBRegisterXPathExtensions(xmlXPathContextPtr xpathCtx) +{ + xmlXPathRegisterFunc(xpathCtx, BAD_CAST "matches", FBXPathMatchesFunction); + xmlXPathRegisterFunc(xpathCtx, BAD_CAST "ends-with", FBXPathEndsWithFunction); + xmlXPathRegisterFunc(xpathCtx, BAD_CAST "lower-case", FBXPathLowerCaseFunction); + xmlXPathRegisterFunc(xpathCtx, BAD_CAST "upper-case", FBXPathUpperCaseFunction); + xmlXPathRegisterFunc(xpathCtx, BAD_CAST "replace", FBXPathReplaceFunction); + xmlXPathRegisterFunc(xpathCtx, BAD_CAST "tokenize", FBXPathTokenizeFunction); + xmlXPathRegisterFunc(xpathCtx, BAD_CAST "string-join", FBXPathStringJoinFunction); +} diff --git a/WebDriverAgentLib/Utilities/XCTestPrivateSymbols.h b/WebDriverAgentLib/Utilities/XCTestPrivateSymbols.h index 39cdf6b76..d6460b131 100644 --- a/WebDriverAgentLib/Utilities/XCTestPrivateSymbols.h +++ b/WebDriverAgentLib/Utilities/XCTestPrivateSymbols.h @@ -50,8 +50,8 @@ extern NSArray *(*XCAXAccessibilityAttributesForStringAttributes)(id */ void *FBRetrieveXCTestSymbol(const char *name); -/*! Static constructor that will retrieve XCTest private symbols */ -__attribute__((constructor)) void FBLoadXCTestSymbols(void); +/*! Loads XCTest private symbols. Safe to call multiple times. */ +void FBLoadXCTestSymbols(void); /** Method is used to tranform attribute names into the format, which diff --git a/WebDriverAgentLib/Utilities/XCTestPrivateSymbols.m b/WebDriverAgentLib/Utilities/XCTestPrivateSymbols.m index 21a9ba300..60ac241a7 100644 --- a/WebDriverAgentLib/Utilities/XCTestPrivateSymbols.m +++ b/WebDriverAgentLib/Utilities/XCTestPrivateSymbols.m @@ -30,41 +30,61 @@ NSArray *(*XCAXAccessibilityAttributesForStringAttributes)(id); -__attribute__((constructor)) void FBLoadXCTestSymbols(void) +@interface FBXCTestSymbolsLoader : NSObject +@end + +@implementation FBXCTestSymbolsLoader + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wobjc-load-method" + ++ (void)load { - NSString *XC_kAXXCAttributeIsVisible = *(NSString*__autoreleasing*)FBRetrieveXCTestSymbol([FB_XCAXAIsVisibleAttributeName UTF8String]); - NSString *XC_kAXXCAttributeIsElement = *(NSString*__autoreleasing*)FBRetrieveXCTestSymbol([FB_XCAXAIsElementAttributeName UTF8String]); - - XCAXAccessibilityAttributesForStringAttributes = - (NSArray *(*)(id))FBRetrieveXCTestSymbol("XCAXAccessibilityAttributesForStringAttributes"); - - XCSetDebugLogger = (void (*)(id ))FBRetrieveXCTestSymbol("XCSetDebugLogger"); - XCDebugLogger = (id(*)(void))FBRetrieveXCTestSymbol("XCDebugLogger"); - - NSArray *accessibilityAttributes = XCAXAccessibilityAttributesForStringAttributes(@[XC_kAXXCAttributeIsVisible, XC_kAXXCAttributeIsElement]); - FB_XCAXAIsVisibleAttribute = accessibilityAttributes[0]; - FB_XCAXAIsElementAttribute = accessibilityAttributes[1]; - - NSCAssert(FB_XCAXAIsVisibleAttribute != nil , @"Failed to retrieve FB_XCAXAIsVisibleAttribute", FB_XCAXAIsVisibleAttribute); - NSCAssert(FB_XCAXAIsElementAttribute != nil , @"Failed to retrieve FB_XCAXAIsElementAttribute", FB_XCAXAIsElementAttribute); - - NSString *XC_kAXXCAttributeMinValue = *(NSString *__autoreleasing *)FBRetrieveXCTestSymbol([FB_XCAXACustomMinValueAttributeName UTF8String]); - NSString *XC_kAXXCAttributeMaxValue = *(NSString *__autoreleasing *)FBRetrieveXCTestSymbol([FB_XCAXACustomMaxValueAttributeName UTF8String]); - - NSString *XC_kAXXCAttributeCustomActions = *(NSString *__autoreleasing *)FBRetrieveXCTestSymbol([FB_XCAXACustomActionsAttributeName UTF8String]); - - NSArray *customAttrs = XCAXAccessibilityAttributesForStringAttributes(@[ - XC_kAXXCAttributeMinValue, - XC_kAXXCAttributeMaxValue, - XC_kAXXCAttributeCustomActions - ]); - FB_XCAXACustomMinValueAttribute = customAttrs[0]; - FB_XCAXACustomMaxValueAttribute = customAttrs[1]; - FB_XCAXACustomActionsAttribute = customAttrs[2]; - - NSCAssert(FB_XCAXACustomMinValueAttribute != nil, @"Failed to retrieve FB_XCAXACustomMinValueAttribute", FB_XCAXACustomMinValueAttribute); - NSCAssert(FB_XCAXACustomMaxValueAttribute != nil, @"Failed to retrieve FB_XCAXACustomMaxValueAttribute", FB_XCAXACustomMaxValueAttribute); - NSCAssert(FB_XCAXACustomActionsAttribute != nil, @"Failed to retrieve FB_XCAXACustomActionsAttribute", FB_XCAXACustomActionsAttribute); + FBLoadXCTestSymbols(); +} + +#pragma clang diagnostic pop + +@end + +void FBLoadXCTestSymbols(void) +{ + static dispatch_once_t loadOnceToken; + dispatch_once(&loadOnceToken, ^{ + NSString *XC_kAXXCAttributeIsVisible = *(NSString*__autoreleasing*)FBRetrieveXCTestSymbol([FB_XCAXAIsVisibleAttributeName UTF8String]); + NSString *XC_kAXXCAttributeIsElement = *(NSString*__autoreleasing*)FBRetrieveXCTestSymbol([FB_XCAXAIsElementAttributeName UTF8String]); + + XCAXAccessibilityAttributesForStringAttributes = + (NSArray *(*)(id))FBRetrieveXCTestSymbol("XCAXAccessibilityAttributesForStringAttributes"); + + XCSetDebugLogger = (void (*)(id ))FBRetrieveXCTestSymbol("XCSetDebugLogger"); + XCDebugLogger = (id(*)(void))FBRetrieveXCTestSymbol("XCDebugLogger"); + + NSArray *accessibilityAttributes = XCAXAccessibilityAttributesForStringAttributes(@[XC_kAXXCAttributeIsVisible, XC_kAXXCAttributeIsElement]); + FB_XCAXAIsVisibleAttribute = accessibilityAttributes[0]; + FB_XCAXAIsElementAttribute = accessibilityAttributes[1]; + + NSCAssert(FB_XCAXAIsVisibleAttribute != nil , @"Failed to retrieve FB_XCAXAIsVisibleAttribute", FB_XCAXAIsVisibleAttribute); + NSCAssert(FB_XCAXAIsElementAttribute != nil , @"Failed to retrieve FB_XCAXAIsElementAttribute", FB_XCAXAIsElementAttribute); + + NSString *XC_kAXXCAttributeMinValue = *(NSString *__autoreleasing *)FBRetrieveXCTestSymbol([FB_XCAXACustomMinValueAttributeName UTF8String]); + NSString *XC_kAXXCAttributeMaxValue = *(NSString *__autoreleasing *)FBRetrieveXCTestSymbol([FB_XCAXACustomMaxValueAttributeName UTF8String]); + + NSString *XC_kAXXCAttributeCustomActions = *(NSString *__autoreleasing *)FBRetrieveXCTestSymbol([FB_XCAXACustomActionsAttributeName UTF8String]); + + NSArray *customAttrs = XCAXAccessibilityAttributesForStringAttributes(@[ + XC_kAXXCAttributeMinValue, + XC_kAXXCAttributeMaxValue, + XC_kAXXCAttributeCustomActions + ]); + FB_XCAXACustomMinValueAttribute = customAttrs[0]; + FB_XCAXACustomMaxValueAttribute = customAttrs[1]; + FB_XCAXACustomActionsAttribute = customAttrs[2]; + + NSCAssert(FB_XCAXACustomMinValueAttribute != nil, @"Failed to retrieve FB_XCAXACustomMinValueAttribute", FB_XCAXACustomMinValueAttribute); + NSCAssert(FB_XCAXACustomMaxValueAttribute != nil, @"Failed to retrieve FB_XCAXACustomMaxValueAttribute", FB_XCAXACustomMaxValueAttribute); + NSCAssert(FB_XCAXACustomActionsAttribute != nil, @"Failed to retrieve FB_XCAXACustomActionsAttribute", FB_XCAXACustomActionsAttribute); + }); } void *FBRetrieveXCTestSymbol(const char *name) diff --git a/WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.m b/WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.m index 56508e809..a30d261b4 100755 --- a/WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.m +++ b/WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.m @@ -92,18 +92,18 @@ // Logging Disabled -#define LogError(frmt, ...) {} -#define LogWarn(frmt, ...) {} -#define LogInfo(frmt, ...) {} -#define LogVerbose(frmt, ...) {} +#define LogError(frmt, ...) do {} while (0) +#define LogWarn(frmt, ...) do {} while (0) +#define LogInfo(frmt, ...) do {} while (0) +#define LogVerbose(frmt, ...) do {} while (0) -#define LogCError(frmt, ...) {} -#define LogCWarn(frmt, ...) {} -#define LogCInfo(frmt, ...) {} -#define LogCVerbose(frmt, ...) {} +#define LogCError(frmt, ...) do {} while (0) +#define LogCWarn(frmt, ...) do {} while (0) +#define LogCInfo(frmt, ...) do {} while (0) +#define LogCVerbose(frmt, ...) do {} while (0) -#define LogTrace() {} -#define LogCTrace(frmt, ...) {} +#define LogTrace() do {} while (0) +#define LogCTrace(frmt, ...) do {} while (0) #endif @@ -251,7 +251,7 @@ - (instancetype)initWithCapacity:(size_t)numBytes if ((self = [super init])) { preBufferSize = numBytes; - preBuffer = malloc(preBufferSize); + preBuffer = (uint8_t *)malloc(preBufferSize); readPointer = preBuffer; writePointer = preBuffer; @@ -274,7 +274,7 @@ - (void)ensureCapacityForWrite:(size_t)numBytes size_t additionalBytes = numBytes - availableSpace; size_t newPreBufferSize = preBufferSize + additionalBytes; - uint8_t *newPreBuffer = realloc(preBuffer, newPreBufferSize); + uint8_t *newPreBuffer = (uint8_t *)realloc(preBuffer, newPreBufferSize); size_t readPointerOffset = readPointer - preBuffer; size_t writePointerOffset = writePointer - preBuffer; @@ -794,7 +794,7 @@ - (NSInteger)searchForTermAfterPreBuffering:(ssize_t)numBytes // The implementation of this method is very similar to the above method. // See the above method for a discussion of the algorithm used here. - uint8_t *buff = [buffer mutableBytes]; + uint8_t *buff = (uint8_t *)[buffer mutableBytes]; NSUInteger buffLength = bytesDone + numBytes; const void *termBuff = [term bytes]; @@ -7104,23 +7104,6 @@ - (void)ssl_startTLS return; } -#if !TARGET_OS_IPHONE && (__MAC_OS_X_VERSION_MIN_REQUIRED < 1080) - - // Note from Apple's documentation: - // - // It is only necessary to call SSLSetEnableCertVerify on the Mac prior to OS X 10.8. - // On OS X 10.8 and later setting kSSLSessionOptionBreakOnServerAuth always disables the - // built-in trust evaluation. All versions of iOS behave like OS X 10.8 and thus - // SSLSetEnableCertVerify is not available on that platform at all. - - status = SSLSetEnableCertVerify(sslContext, NO); - if (status != noErr) - { - [self closeWithError:[self otherError:@"Error in SSLSetEnableCertVerify"]]; - return; - } - -#endif } // Configure SSLContext from given settings @@ -7384,21 +7367,12 @@ - (void)ssl_startTLS value = [tlsSettings objectForKey:GCDAsyncSocketSSLALPN]; if ([value isKindOfClass:[NSArray class]]) { - if (@available(iOS 11.0, macOS 10.13, tvOS 11.0, *)) - { - CFArrayRef protocols = (__bridge CFArrayRef)((NSArray *) value); - status = SSLSetALPNProtocols(sslContext, protocols); - if (status != noErr) - { - [self closeWithError:[self otherError:@"Error in SSLSetALPNProtocols"]]; - return; - } - } - else + CFArrayRef protocols = (__bridge CFArrayRef)((NSArray *) value); + status = SSLSetALPNProtocols(sslContext, protocols); + if (status != noErr) { - NSAssert(NO, @"Security option unavailable - GCDAsyncSocketSSLALPN" - @" - iOS 11.0, macOS 10.13 required"); - [self closeWithError:[self otherError:@"Security option unavailable - GCDAsyncSocketSSLALPN"]]; + [self closeWithError:[self otherError:@"Error in SSLSetALPNProtocols"]]; + return; } } else if (value) @@ -8719,7 +8693,7 @@ + (BOOL)isIPv4Address:(NSData *)address { if ([address length] >= sizeof(struct sockaddr)) { - const struct sockaddr *sockaddrX = [address bytes]; + const struct sockaddr *sockaddrX = (const struct sockaddr *)(const void *)[address bytes]; if (sockaddrX->sa_family == AF_INET) { return YES; @@ -8733,7 +8707,7 @@ + (BOOL)isIPv6Address:(NSData *)address { if ([address length] >= sizeof(struct sockaddr)) { - const struct sockaddr *sockaddrX = [address bytes]; + const struct sockaddr *sockaddrX = (const struct sockaddr *)(const void *)[address bytes]; if (sockaddrX->sa_family == AF_INET6) { return YES; @@ -8752,7 +8726,7 @@ + (BOOL)getHost:(NSString **)hostPtr port:(uint16_t *)portPtr family:(sa_family_ { if ([address length] >= sizeof(struct sockaddr)) { - const struct sockaddr *sockaddrX = [address bytes]; + const struct sockaddr *sockaddrX = (const struct sockaddr *)(const void *)[address bytes]; if (sockaddrX->sa_family == AF_INET) { diff --git a/WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.m b/WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.m index 529aa13ff..23d05e730 100755 --- a/WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.m +++ b/WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.m @@ -70,18 +70,18 @@ // Logging Disabled -#define LogError(frmt, ...) {} -#define LogWarn(frmt, ...) {} -#define LogInfo(frmt, ...) {} -#define LogVerbose(frmt, ...) {} +#define LogError(frmt, ...) do {} while (0) +#define LogWarn(frmt, ...) do {} while (0) +#define LogInfo(frmt, ...) do {} while (0) +#define LogVerbose(frmt, ...) do {} while (0) -#define LogCError(frmt, ...) {} -#define LogCWarn(frmt, ...) {} -#define LogCInfo(frmt, ...) {} -#define LogCVerbose(frmt, ...) {} +#define LogCError(frmt, ...) do {} while (0) +#define LogCWarn(frmt, ...) do {} while (0) +#define LogCInfo(frmt, ...) do {} while (0) +#define LogCVerbose(frmt, ...) do {} while (0) -#define LogTrace() {} -#define LogCTrace(frmt, ...) {} +#define LogTrace() do {} while (0) +#define LogCTrace(frmt, ...) do {} while (0) #endif @@ -4310,12 +4310,12 @@ - (void)doSend if (currentSend->addressFamily == AF_INET) { - result = sendto(socket4FD, buffer, length, 0, dst, dstSize); + result = sendto(socket4FD, buffer, length, 0, (const struct sockaddr *)dst, dstSize); LogVerbose(@"sendto(socket4FD) = %d", result); } else { - result = sendto(socket6FD, buffer, length, 0, dst, dstSize); + result = sendto(socket6FD, buffer, length, 0, (const struct sockaddr *)dst, dstSize); LogVerbose(@"sendto(socket6FD) = %d", result); } } diff --git a/WebDriverAgentLib/Vendor/CocoaHTTPServer/Categories/DDRange.m b/WebDriverAgentLib/Vendor/CocoaHTTPServer/Categories/DDRange.m index 4aef974d7..d8c8c70ca 100644 --- a/WebDriverAgentLib/Vendor/CocoaHTTPServer/Categories/DDRange.m +++ b/WebDriverAgentLib/Vendor/CocoaHTTPServer/Categories/DDRange.m @@ -5,27 +5,21 @@ DDRange DDUnionRange(DDRange range1, DDRange range2) { - DDRange result; - - result.location = MIN(range1.location, range2.location); - result.length = MAX(DDMaxRange(range1), DDMaxRange(range2)) - result.location; - - return result; + UInt64 location = MIN(range1.location, range2.location); + UInt64 length = MAX(DDMaxRange(range1), DDMaxRange(range2)) - location; + + return DDMakeRange(location, length); } DDRange DDIntersectionRange(DDRange range1, DDRange range2) { - DDRange result; - if((DDMaxRange(range1) < range2.location) || (DDMaxRange(range2) < range1.location)) { return DDMakeRange(0, 0); } - result.location = MAX(range1.location, range2.location); - result.length = MIN(DDMaxRange(range1), DDMaxRange(range2)) - result.location; - - return result; + return DDMakeRange(MAX(range1.location, range2.location), + MIN(DDMaxRange(range1), DDMaxRange(range2)) - MAX(range1.location, range2.location)); } NSString *DDStringFromRange(DDRange range) diff --git a/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPConnection.h b/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPConnection.h index e1868532f..8d409bf71 100644 --- a/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPConnection.h +++ b/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPConnection.h @@ -85,8 +85,10 @@ - (void)prepareForBodyWithSize:(UInt64)contentLength; - (void)processBodyData:(NSData *)postDataChunk; - (void)finishBody; +- (UInt64)maxRequestBodySize; - (void)handleVersionNotSupported:(NSString *)version; +- (void)handleRequestBodyTooLarge; - (void)handleResourceNotFound; - (void)handleInvalidRequest:(NSData *)data; - (void)handleUnknownMethod:(NSString *)method; diff --git a/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPConnection.m b/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPConnection.m index 064f653b8..3e2a11893 100644 --- a/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPConnection.m +++ b/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPConnection.m @@ -1301,6 +1301,14 @@ - (void)finishBody // the hook to flush any pending data to disk and maybe close the file. } +/** + * Returns the maximum request body size this connection accepts. + **/ +- (UInt64)maxRequestBodySize +{ + return (UInt64)-1; +} + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// #pragma mark Errors //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -1324,6 +1332,21 @@ - (void)handleVersionNotSupported:(NSString *)version } +/** + * Called if the HTTP request body is larger than the configured limit. + **/ +- (void)handleRequestBodyTooLarge +{ + HTTPLogWarn(@"HTTP Server: Error 413 - Request Entity Too Large (%@)", [self requestURI]); + + HTTPMessage *response = [[HTTPMessage alloc] initResponseWithStatusCode:413 description:nil version:HTTPVersion1_1]; + [response setHeaderField:@"Content-Length" value:@"0"]; + [response setHeaderField:@"Connection" value:@"close"]; + + NSData *responseData = [self preprocessErrorResponse:response]; + [asyncSocket writeData:responseData withTimeout:TIMEOUT_WRITE_ERROR tag:HTTP_FINAL_RESPONSE]; +} + /** * Called if we receive some sort of malformed HTTP request. * The data parameter is the invalid HTTP header line, including CRLF, as read from GCDAsyncSocket. @@ -1617,6 +1640,15 @@ - (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData*)data withTag:(long)ta [self handleInvalidRequest:nil]; return; } + + if (requestContentLength > [self maxRequestBodySize]) + { + HTTPLogWarn(@"%@[%p]: Request body size %llu exceeds the configured limit %llu", + THIS_FILE, self, requestContentLength, [self maxRequestBodySize]); + + [self handleRequestBodyTooLarge]; + return; + } } } else @@ -1749,6 +1781,17 @@ - (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData*)data withTag:(long)ta if (requestChunkSize > 0) { + UInt64 maxRequestBodySize = [self maxRequestBodySize]; + if (requestChunkSize > maxRequestBodySize || + requestContentLengthReceived > maxRequestBodySize - requestChunkSize) + { + HTTPLogWarn(@"%@[%p]: Chunked request body exceeds the configured limit %llu", + THIS_FILE, self, maxRequestBodySize); + + [self handleRequestBodyTooLarge]; + return; + } + NSUInteger bytesToRead; bytesToRead = (requestChunkSize < POST_CHUNKSIZE) ? (NSUInteger)requestChunkSize : POST_CHUNKSIZE; diff --git a/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPLogging.h b/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPLogging.h index 84ee8da04..4c277f1db 100644 --- a/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPLogging.h +++ b/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPLogging.h @@ -95,28 +95,28 @@ // Define logging primitives. -#define HTTPLogError(...) { } +#define HTTPLogError(...) do {} while (0) -#define HTTPLogWarn(...) { } +#define HTTPLogWarn(...) do {} while (0) -#define HTTPLogInfo(...) { } +#define HTTPLogInfo(...) do {} while (0) -#define HTTPLogVerbose(...) { } +#define HTTPLogVerbose(...) do {} while (0) -#define HTTPLogTrace() { } +#define HTTPLogTrace() do {} while (0) -#define HTTPLogTrace2(...) { } +#define HTTPLogTrace2(...) do {} while (0) -#define HTTPLogCError(...) { } +#define HTTPLogCError(...) do {} while (0) -#define HTTPLogCWarn(...) { } +#define HTTPLogCWarn(...) do {} while (0) -#define HTTPLogCInfo(...) { } +#define HTTPLogCInfo(...) do {} while (0) -#define HTTPLogCVerbose(...) { } +#define HTTPLogCVerbose(...) do {} while (0) -#define HTTPLogCTrace() { } +#define HTTPLogCTrace() do {} while (0) -#define HTTPLogCTrace2(...) { } +#define HTTPLogCTrace2(...) do {} while (0) diff --git a/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPMessage.m b/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPMessage.m index 920f7a280..44eaee180 100644 --- a/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPMessage.m +++ b/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPMessage.m @@ -68,13 +68,13 @@ - (BOOL)appendData:(NSData *)data { // Look for the end of headers (CRLF CRLF or LF LF) NSData *headerEndMarker = [@"\r\n\r\n" dataUsingEncoding:NSASCIIStringEncoding]; - NSRange headerEndRange = [_rawData rangeOfData:headerEndMarker options:0 range:NSMakeRange(0, [_rawData length])]; + NSRange headerEndRange = [_rawData rangeOfData:headerEndMarker options:(NSDataSearchOptions)0 range:NSMakeRange(0, [_rawData length])]; if (headerEndRange.location == NSNotFound) { // Also check for LF LF (some clients use this) NSData *lfMarker = [@"\n\n" dataUsingEncoding:NSASCIIStringEncoding]; - headerEndRange = [_rawData rangeOfData:lfMarker options:0 range:NSMakeRange(0, [_rawData length])]; + headerEndRange = [_rawData rangeOfData:lfMarker options:(NSDataSearchOptions)0 range:NSMakeRange(0, [_rawData length])]; } if (headerEndRange.location != NSNotFound) diff --git a/WebDriverAgentLib/Vendor/CocoaHTTPServer/Responses/HTTPErrorResponse.m b/WebDriverAgentLib/Vendor/CocoaHTTPServer/Responses/HTTPErrorResponse.m index 3309cf164..a11552008 100644 --- a/WebDriverAgentLib/Vendor/CocoaHTTPServer/Responses/HTTPErrorResponse.m +++ b/WebDriverAgentLib/Vendor/CocoaHTTPServer/Responses/HTTPErrorResponse.m @@ -22,9 +22,7 @@ - (UInt64) offset { return 0; } -- (void)setOffset:(UInt64)offset { - ; -} +- (void)setOffset:(UInt64)offset {} - (NSData*) readDataOfLength:(NSUInteger)length { return nil; diff --git a/WebDriverAgentLib/Vendor/RoutingHTTPServer/RoutingHTTPServer.m b/WebDriverAgentLib/Vendor/RoutingHTTPServer/RoutingHTTPServer.m index 56df1cd3f..68e6a274a 100644 --- a/WebDriverAgentLib/Vendor/RoutingHTTPServer/RoutingHTTPServer.m +++ b/WebDriverAgentLib/Vendor/RoutingHTTPServer/RoutingHTTPServer.m @@ -149,16 +149,16 @@ - (Route *)routeWithPath:(NSString *)path { NSRegularExpression *regex = nil; // Escape regex characters - regex = [NSRegularExpression regularExpressionWithPattern:@"[.+()]" options:0 error:nil]; - path = [regex stringByReplacingMatchesInString:path options:0 range:NSMakeRange(0, path.length) withTemplate:@"\\\\$0"]; + regex = [NSRegularExpression regularExpressionWithPattern:@"[.+()]" options:(NSRegularExpressionOptions)0 error:nil]; + path = [regex stringByReplacingMatchesInString:path options:(NSMatchingOptions)0 range:NSMakeRange(0, path.length) withTemplate:@"\\\\$0"]; // Parse any :parameters and * in the path regex = [NSRegularExpression regularExpressionWithPattern:@"(:(\\w+)|\\*)" - options:0 + options:(NSRegularExpressionOptions)0 error:nil]; NSMutableString *regexPath = [NSMutableString stringWithString:path]; __block NSInteger diff = 0; - [regex enumerateMatchesInString:path options:0 range:NSMakeRange(0, path.length) + [regex enumerateMatchesInString:path options:(NSMatchingOptions)0 range:NSMakeRange(0, path.length) usingBlock:^(NSTextCheckingResult *result, NSMatchingFlags flags, BOOL *stop) { NSRange replacementRange = NSMakeRange(diff + result.range.location, result.range.length); NSString *replacementString; @@ -219,7 +219,7 @@ - (RouteResponse *)routeMethod:(NSString *)method return nil; for (Route *route in methodRoutes) { - NSTextCheckingResult *result = [route.regex firstMatchInString:path options:0 range:NSMakeRange(0, path.length)]; + NSTextCheckingResult *result = [route.regex firstMatchInString:path options:(NSMatchingOptions)0 range:NSMakeRange(0, path.length)]; if (!result) continue; diff --git a/WebDriverAgentLib/WebDriverAgentLib.h b/WebDriverAgentLib/WebDriverAgentLib.h index 8645b63ec..f38e851c5 100644 --- a/WebDriverAgentLib/WebDriverAgentLib.h +++ b/WebDriverAgentLib/WebDriverAgentLib.h @@ -71,6 +71,7 @@ FOUNDATION_EXPORT const unsigned char WebDriverAgentLib_VersionString[]; #import #import #import +#import #import #import #import diff --git a/WebDriverAgentLib/include/WebDriverAgentLib/XCUIDevice+FBVoiceOver.h b/WebDriverAgentLib/include/WebDriverAgentLib/XCUIDevice+FBVoiceOver.h new file mode 120000 index 000000000..e48c0c5a7 --- /dev/null +++ b/WebDriverAgentLib/include/WebDriverAgentLib/XCUIDevice+FBVoiceOver.h @@ -0,0 +1 @@ +../../Categories/XCUIDevice+FBVoiceOver.h \ No newline at end of file diff --git a/WebDriverAgentRunner/Assets.xcassets/AppIcon.appiconset/Contents.json b/WebDriverAgentRunner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..27a4f3814 --- /dev/null +++ b/WebDriverAgentRunner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WebDriverAgentRunner/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/WebDriverAgentRunner/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 000000000..373e26bd1 Binary files /dev/null and b/WebDriverAgentRunner/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/WebDriverAgentRunner/Assets.xcassets/Contents.json b/WebDriverAgentRunner/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/WebDriverAgentRunner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WebDriverAgentTests/IntegrationTests/FBElementAttributeTests.m b/WebDriverAgentTests/IntegrationTests/FBElementAttributeTests.m index ddc7a2805..1012b6722 100644 --- a/WebDriverAgentTests/IntegrationTests/FBElementAttributeTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBElementAttributeTests.m @@ -41,6 +41,15 @@ - (void)testElementAccessibilityAttributes XCTAssertFalse(buttonElement.isWDAccessibilityContainer); } +- (void)testNativeAccessibilityElementAttribute +{ + // wdNativeAccessibilityElement must expose the raw, native isAccessibilityElement flag + // without WebDriverAgent's custom computation applied by wdAccessible + XCUIElement *buttonElement = self.testedApplication.buttons[@"Button"]; + XCTAssertTrue(buttonElement.exists); + XCTAssertEqual(buttonElement.wdNativeAccessibilityElement, buttonElement.fb_isAccessibilityElement); +} + - (void)testContainerAccessibilityAttributes { // "not_accessible" isn't accessibility element, but contains accessibility elements, so it is accessibility container diff --git a/WebDriverAgentTests/IntegrationTests/FBVoiceOverTests.m b/WebDriverAgentTests/IntegrationTests/FBVoiceOverTests.m new file mode 100644 index 000000000..ba9d10dfd --- /dev/null +++ b/WebDriverAgentTests/IntegrationTests/FBVoiceOverTests.m @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import "FBIntegrationTestCase.h" +#import "FBMacros.h" +#import "FBTestMacros.h" +#import "XCUIDevice+FBVoiceOver.h" + +@interface FBVoiceOverTests : FBIntegrationTestCase +@end + +@implementation FBVoiceOverTests + +- (void)tearDown +{ + if ([XCUIDevice.sharedDevice fb_isVoiceOverServiceAvailable]) { + NSError *error = nil; + if ([XCUIDevice.sharedDevice fb_isVoiceOverEnabled:&error] && nil == error) { + [XCUIDevice.sharedDevice fb_disableVoiceOver:&error]; + } + } + [super tearDown]; +} + +- (void)testVoiceOverUnavailableOnOlderSDK +{ + if ([XCUIDevice.sharedDevice fb_isVoiceOverServiceAvailable]) { + return; + } + + NSError *error = nil; + XCTAssertFalse([XCUIDevice.sharedDevice fb_enableVoiceOver:&error]); + XCTAssertNotNil(error); + XCTAssertTrue([error.localizedDescription containsString:@"iOS 27"]); +} + +- (void)testVoiceOverEnableDisableAndNavigation +{ + if (SYSTEM_VERSION_LESS_THAN(@"27.0")) { + return; + } + if (![XCUIDevice.sharedDevice fb_isVoiceOverServiceAvailable]) { + return; + } + + [self launchApplication]; + + NSError *error = nil; + XCTAssertTrue([XCUIDevice.sharedDevice fb_enableVoiceOver:&error]); + XCTAssertNil(error); + XCTAssertTrue([XCUIDevice.sharedDevice fb_isVoiceOverEnabled:&error]); + XCTAssertNil(error); + + NSString *utterance = [XCUIDevice.sharedDevice fb_voiceOverMove:@"forward" error:&error]; + XCTAssertNil(error); + XCTAssertNotNil(utterance); + XCTAssertTrue(utterance.length > 0); + + NSString *currentSpeech = [XCUIDevice.sharedDevice fb_voiceOverCurrentSpeech:&error]; + XCTAssertNil(error); + XCTAssertNotNil(currentSpeech); + XCTAssertEqualObjects(currentSpeech, utterance); + + XCTAssertTrue([XCUIDevice.sharedDevice fb_disableVoiceOver:&error]); + XCTAssertNil(error); + XCTAssertFalse([XCUIDevice.sharedDevice fb_isVoiceOverEnabled:&error]); + XCTAssertNil(error); +} + +#if TARGET_OS_IOS +- (void)testVoiceOverMoveBackward +{ + if (SYSTEM_VERSION_LESS_THAN(@"27.0")) { + return; + } + if (![XCUIDevice.sharedDevice fb_isVoiceOverServiceAvailable]) { + return; + } + + [self launchApplication]; + + NSError *error = nil; + XCTAssertTrue([XCUIDevice.sharedDevice fb_enableVoiceOver:&error]); + XCTAssertNil(error); + + XCTAssertNotNil([XCUIDevice.sharedDevice fb_voiceOverMove:@"forward" error:&error]); + XCTAssertNil(error); + + NSString *utterance = [XCUIDevice.sharedDevice fb_voiceOverMove:@"backward" error:&error]; + XCTAssertNil(error); + XCTAssertNotNil(utterance); + XCTAssertTrue(utterance.length > 0); +} +#endif + +@end diff --git a/WebDriverAgentTests/IntegrationTests/FBXPathIntegrationTests.m b/WebDriverAgentTests/IntegrationTests/FBXPathIntegrationTests.m index 7082224ef..4d9d5874a 100644 --- a/WebDriverAgentTests/IntegrationTests/FBXPathIntegrationTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBXPathIntegrationTests.m @@ -9,6 +9,7 @@ #import #import "FBIntegrationTestCase.h" +#import "FBExceptions.h" #import "FBMacros.h" #import "FBTestMacros.h" #import "FBXPath.h" @@ -51,6 +52,31 @@ - (void)setUp return snapshot; } +- (NSSet *)labelsForMatchingSnapshots:(NSArray> *)matchingSnapshots +{ + NSMutableSet *labels = [NSMutableSet set]; + for (id snapshot in matchingSnapshots) { + NSString *label = [FBXCElementSnapshotWrapper ensureWrapped:snapshot].wdLabel; + if (nil != label) { + [labels addObject:label]; + } + } + return labels.copy; +} + +- (void)assertXPathQuery:(NSString *)query findsButtonLabels:(NSArray *)expectedLabels +{ + NSArray> *matchingSnapshots = [FBXPath matchesWithRootElement:self.testedApplication + forQuery:query]; + NSSet *foundLabels = [self labelsForMatchingSnapshots:matchingSnapshots]; + NSSet *expectedLabelSet = [NSSet setWithArray:expectedLabels]; + XCTAssertEqual(foundLabels.count, expectedLabelSet.count); + XCTAssertEqualObjects(foundLabels, expectedLabelSet); + for (id snapshot in matchingSnapshots) { + XCTAssertEqualObjects([FBXCElementSnapshotWrapper ensureWrapped:snapshot].wdType, @"XCUIElementTypeButton"); + } +} + - (void)testApplicationNodeXMLRepresentation { id snapshot = [self.testedApplication fb_customSnapshot]; @@ -154,4 +180,81 @@ - (void)testFindMatchesInElementWithDotNotation } } +- (void)testFindMatchesWithMatchesFunction +{ + [self assertXPathQuery:@"//XCUIElementTypeButton[matches(@label, '^Alerts$')]" + findsButtonLabels:@[@"Alerts"]]; +} + +- (void)testFindMatchesWithMatchesFunctionCaseInsensitive +{ + [self assertXPathQuery:@"//XCUIElementTypeButton[matches(@label, '^alerts$', 'i')]" + findsButtonLabels:@[@"Alerts"]]; +} + +- (void)testFindMatchesWithEndsWithFunction +{ + [self assertXPathQuery:@"//XCUIElementTypeButton[ends-with(@label, 'ing')]" + findsButtonLabels:@[@"Scrolling"]]; +} + +- (void)testFindMatchesWithLowerCaseFunction +{ + [self assertXPathQuery:@"//XCUIElementTypeButton[lower-case(@label)='alerts']" + findsButtonLabels:@[@"Alerts"]]; +} + +- (void)testFindMatchesWithUpperCaseFunction +{ + [self assertXPathQuery:@"//XCUIElementTypeButton[upper-case(@label)='TOUCH']" + findsButtonLabels:@[@"Touch"]]; +} + +- (void)testFindMatchesWithReplaceFunction +{ + [self assertXPathQuery:@"//XCUIElementTypeButton[replace(@label, ' ', '')='Deadlockapp']" + findsButtonLabels:@[@"Deadlock app"]]; +} + +- (void)testFindMatchesWithTokenizeAndStringJoinFunctions +{ + [self assertXPathQuery:@"//XCUIElementTypeButton[string-join(tokenize(@label, ' '), '-')='Deadlock-app']" + findsButtonLabels:@[@"Deadlock app"]]; +} + +- (void)testFindMatchesWithExtensionFunctionsNoMatches +{ + NSArray> *matchingSnapshots = [FBXPath matchesWithRootElement:self.testedApplication + forQuery:@"//XCUIElementTypeButton[matches(@label, '^NoSuchButton$')]"]; + XCTAssertEqual(matchingSnapshots.count, 0); +} + +- (void)testFindMultipleMatchesWithMatchesFunction +{ + [self assertXPathQuery:@"//XCUIElementTypeButton[matches(@label, '.*')]" + findsButtonLabels:@[@"Alerts", @"Deadlock app", @"Attributes", @"Scrolling", @"Touch"]]; +} + +- (void)testInvalidXPathExtensionFunctionViaElementLookup +{ + XCTAssertThrowsSpecificNamed([self.testedView fb_descendantsMatchingXPathQuery:@"//XCUIElementTypeButton[matches(@label)]" + shouldReturnAfterFirstMatch:NO], + NSException, + FBInvalidXPathException); +} + +- (void)testInvalidXPathExtensionRegexpViaElementLookup +{ + NSException *exception = nil; + @try { + [self.testedView fb_descendantsMatchingXPathQuery:@"//XCUIElementTypeButton[matches(@label, '[')]" + shouldReturnAfterFirstMatch:NO]; + } @catch (NSException *caughtException) { + exception = caughtException; + } + XCTAssertEqualObjects(exception.name, FBInvalidXPathException); + XCTAssertTrue([exception.reason containsString:@"Cannot evaluate results for XPath expression"]); + XCTAssertTrue([exception.reason rangeOfString:@"invalid" options:NSCaseInsensitiveSearch].location != NSNotFound); +} + @end diff --git a/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.h b/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.h index cc57b86e8..1e3797e37 100644 --- a/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.h +++ b/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.h @@ -31,6 +31,7 @@ @property (nonatomic, readwrite) NSUInteger wdIndex; @property (nonatomic, readwrite, getter=isWDVisible) BOOL wdVisible; @property (nonatomic, readwrite, getter=isWDAccessible) BOOL wdAccessible; +@property (nonatomic, readwrite, getter=isWDNativeAccessibilityElement) BOOL wdNativeAccessibilityElement; @property (nonatomic, readwrite, getter = isWDFocused) BOOL wdFocused; @property (nonatomic, readwrite, getter = isWDHittable) BOOL wdHittable; @property (nonatomic, copy, readwrite, nullable) NSString *wdPlaceholderValue; diff --git a/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.m b/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.m index 18f7b1202..e51dfdbb2 100644 --- a/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.m +++ b/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.m @@ -28,6 +28,7 @@ - (id)init self.wdCustomActions = nil; self.wdVisible = YES; self.wdAccessible = YES; + self.wdNativeAccessibilityElement = YES; self.wdEnabled = YES; self.wdSelected = YES; self.wdFocused = YES; diff --git a/WebDriverAgentTests/UnitTests/FBConfigurationTests.m b/WebDriverAgentTests/UnitTests/FBConfigurationTests.m index 6d25ea6b0..3f37228fb 100644 --- a/WebDriverAgentTests/UnitTests/FBConfigurationTests.m +++ b/WebDriverAgentTests/UnitTests/FBConfigurationTests.m @@ -22,6 +22,7 @@ - (void)setUp unsetenv("USE_PORT"); unsetenv("USE_IP"); unsetenv("VERBOSE_LOGGING"); + unsetenv("MAX_HTTP_REQUEST_BODY_SIZE"); } - (void)testBindingPortDefault @@ -57,4 +58,15 @@ - (void)testBindingIPEnvironmentOverwrite XCTAssertEqualObjects([FBConfiguration bindingIPAddress], @"192.168.1.100"); } +- (void)testHttpRequestBodySizeLimitDefault +{ + XCTAssertEqual([FBConfiguration httpRequestBodySizeLimit], 1024ull * 1024ull * 1024ull); +} + +- (void)testHttpRequestBodySizeLimitEnvironmentOverwrite +{ + setenv("MAX_HTTP_REQUEST_BODY_SIZE", "1024", 1); + XCTAssertEqual([FBConfiguration httpRequestBodySizeLimit], 1024ull); +} + @end diff --git a/WebDriverAgentTests/UnitTests/FBXPathTests.m b/WebDriverAgentTests/UnitTests/FBXPathTests.m index fef61b3ef..2255436aa 100644 --- a/WebDriverAgentTests/UnitTests/FBXPathTests.m +++ b/WebDriverAgentTests/UnitTests/FBXPathTests.m @@ -92,8 +92,8 @@ - (void)testXPathPresentationBasedOnQueryMatchingAllAttributes NSString *resultXml = [self xmlStringWithElement:(id)element xpathQuery:[NSString stringWithFormat:@"//%@[@*]", element.wdType] excludingAttributes:@[@"visible"]]; - NSString *expectedXml = [NSString stringWithFormat:@"\n<%@ type=\"%@\" value=\"%@\" name=\"%@\" label=\"%@\" enabled=\"%@\" visible=\"%@\" accessible=\"%@\" x=\"%@\" y=\"%@\" width=\"%@\" height=\"%@\" index=\"%lu\" hittable=\"%@\" traits=\"%@\" nativeFrame=\"%@\" customActions=\"%@\" private_indexPath=\"top\"/>\n", - element.wdType, element.wdType, @"йоло<>&"", element.wdName, @"a b", FBBoolToString(element.wdEnabled), FBBoolToString(element.wdVisible), FBBoolToString(element.wdAccessible), element.wdRect[@"x"], element.wdRect[@"y"], element.wdRect[@"width"], element.wdRect[@"height"], element.wdIndex, FBBoolToString(element.wdHittable), element.wdTraits, NSStringFromCGRect(element.wdNativeFrame), element.wdCustomActions]; + NSString *expectedXml = [NSString stringWithFormat:@"\n<%@ type=\"%@\" value=\"%@\" name=\"%@\" label=\"%@\" enabled=\"%@\" visible=\"%@\" accessible=\"%@\" nativeAccessibilityElement=\"%@\" x=\"%@\" y=\"%@\" width=\"%@\" height=\"%@\" index=\"%lu\" hittable=\"%@\" traits=\"%@\" nativeFrame=\"%@\" customActions=\"%@\" private_indexPath=\"top\"/>\n", + element.wdType, element.wdType, @"йоло<>&"", element.wdName, @"a b", FBBoolToString(element.wdEnabled), FBBoolToString(element.wdVisible), FBBoolToString(element.wdAccessible), FBBoolToString(element.wdNativeAccessibilityElement), element.wdRect[@"x"], element.wdRect[@"y"], element.wdRect[@"width"], element.wdRect[@"height"], element.wdIndex, FBBoolToString(element.wdHittable), element.wdTraits, NSStringFromCGRect(element.wdNativeFrame), element.wdCustomActions]; XCTAssertEqualObjects(expectedXml, resultXml); } @@ -152,4 +152,129 @@ - (void)testSnapshotXPathResultsMatching XCTAssertEqual(1, [matchingSnapshots count]); } +- (NSString *)xpathStringResultForQuery:(NSString *)query document:(xmlDocPtr)doc +{ + xmlXPathObjectPtr queryResult = [FBXPath evaluate:query document:doc contextNode:NULL]; + if (NULL == queryResult) { + return nil; + } + xmlChar *stringValue = xmlXPathCastToString(queryResult); + xmlXPathFreeObject(queryResult); + if (NULL == stringValue) { + return nil; + } + NSString *result = [NSString stringWithUTF8String:(const char *)stringValue]; + xmlFree(stringValue); + return result; +} + +- (BOOL)xpathBooleanResultForQuery:(NSString *)query document:(xmlDocPtr)doc +{ + xmlXPathObjectPtr queryResult = [FBXPath evaluate:query document:doc contextNode:NULL]; + if (NULL == queryResult) { + return NO; + } + BOOL result = queryResult->boolval; + xmlXPathFreeObject(queryResult); + return result; +} + +- (xmlDocPtr)documentForSnapshot:(XCElementSnapshotDouble *)snapshot query:(NSString *)query +{ + xmlDocPtr doc; + xmlTextWriterPtr writer = xmlNewTextWriterDoc(&doc, 0); + NSMutableDictionary *elementStore = [NSMutableDictionary dictionary]; + id root = (id)[FBXCElementSnapshotWrapper ensureWrapped:(id)snapshot]; + int rc = xmlTextWriterStartDocument(writer, NULL, "UTF-8", NULL); + if (rc >= 0) { + rc = [FBXPath xmlRepresentationWithRootElement:(id)root + writer:writer + elementStore:elementStore + query:query + excludingAttributes:nil]; + if (rc >= 0) { + rc = xmlTextWriterEndDocument(writer); + } + } + xmlFreeTextWriter(writer); + XCTAssertTrue(rc >= 0); + return doc; +} + +- (void)testXPathExtensionFunctions +{ + XCElementSnapshotDouble *snapshot = [XCElementSnapshotDouble new]; + snapshot.label = @"Hello World"; + snapshot.value = @"One-Two-Three"; + + xmlDocPtr doc = [self documentForSnapshot:snapshot query:@"//*[@label and @name and @value]"]; + + @try { + XCTAssertTrue([self xpathBooleanResultForQuery:@"matches(//XCUIElementTypeOther/@label, 'Hello.*')" document:doc]); + XCTAssertFalse([self xpathBooleanResultForQuery:@"matches(//XCUIElementTypeOther/@label, 'hello.*')" document:doc]); + XCTAssertTrue([self xpathBooleanResultForQuery:@"matches(//XCUIElementTypeOther/@label, 'hello.*', 'i')" document:doc]); + XCTAssertTrue([self xpathBooleanResultForQuery:@"ends-with(//XCUIElementTypeOther/@name, 'Name')" document:doc]); + XCTAssertFalse([self xpathBooleanResultForQuery:@"ends-with(//XCUIElementTypeOther/@name, 'Foo')" document:doc]); + XCTAssertEqualObjects([self xpathStringResultForQuery:@"lower-case(//XCUIElementTypeOther/@label)" document:doc], @"hello world"); + XCTAssertEqualObjects([self xpathStringResultForQuery:@"upper-case(//XCUIElementTypeOther/@name)" document:doc], @"TESTNAME"); + XCTAssertEqualObjects([self xpathStringResultForQuery:@"replace(//XCUIElementTypeOther/@value, '-', '_')" document:doc], @"One_Two_Three"); + XCTAssertEqualObjects([self xpathStringResultForQuery:@"string-join(tokenize(//XCUIElementTypeOther/@value, '-'), '|')" document:doc], @"One|Two|Three"); + } @finally { + xmlFreeDoc(doc); + } +} + +- (void)testInvalidXPathExtensionRegexp +{ + XCElementSnapshotDouble *snapshot = [XCElementSnapshotDouble new]; + snapshot.label = @"Hello World"; + snapshot.value = @"One-Two-Three"; + + xmlDocPtr doc = [self documentForSnapshot:snapshot query:@"//*[@label and @name and @value]"]; + + @try { + [self assertXPathEvaluationFailsForQuery:@"matches(//XCUIElementTypeOther/@label, '[')" document:doc]; + [self assertXPathEvaluationFailsForQuery:@"replace(//XCUIElementTypeOther/@label, '[', '')" document:doc]; + [self assertXPathEvaluationFailsForQuery:@"tokenize(//XCUIElementTypeOther/@value, '[')" document:doc]; + [self assertXPathEvaluationFailsForQuery:@"matches(//XCUIElementTypeOther/@label, 'a', 'z')" document:doc]; + [self assertXPathEvaluationFailsForQuery:@"//XCUIElementTypeOther[matches(@label, '[')]" document:doc]; + } @finally { + xmlFreeDoc(doc); + } +} + +- (void)assertXPathEvaluationFailsForQuery:(NSString *)query document:(xmlDocPtr)doc +{ + xmlXPathObjectPtr queryResult = [FBXPath evaluate:query document:doc contextNode:NULL]; + @try { + XCTAssertEqual(NULL, queryResult); + } @finally { + if (NULL != queryResult) { + xmlXPathFreeObject(queryResult); + } + } +} + +- (void)testInvalidXPathExtensionFunctionArity +{ + XCElementSnapshotDouble *snapshot = [XCElementSnapshotDouble new]; + snapshot.label = @"Hello World"; + snapshot.value = @"One-Two-Three"; + + xmlDocPtr doc = [self documentForSnapshot:snapshot query:@"//*[@label and @name and @value]"]; + + @try { + [self assertXPathEvaluationFailsForQuery:@"matches(//XCUIElementTypeOther/@label)" document:doc]; + [self assertXPathEvaluationFailsForQuery:@"lower-case()" document:doc]; + [self assertXPathEvaluationFailsForQuery:@"string-join(//XCUIElementTypeOther/@label)" document:doc]; + [self assertXPathEvaluationFailsForQuery:@"replace(//XCUIElementTypeOther/@label, '-')" document:doc]; + [self assertXPathEvaluationFailsForQuery:@"//XCUIElementTypeOther[matches(@label)]" document:doc]; + [self assertXPathEvaluationFailsForQuery:@"//XCUIElementTypeOther[lower-case()]" document:doc]; + [self assertXPathEvaluationFailsForQuery:@"//XCUIElementTypeOther[string-join(@label)]" document:doc]; + [self assertXPathEvaluationFailsForQuery:@"//XCUIElementTypeOther[replace(@label, '-')]" document:doc]; + } @finally { + xmlFreeDoc(doc); + } +} + @end diff --git a/WebDriverAgentTests/UnitTests_tvOS/Doubles/XCUIElementDouble.h b/WebDriverAgentTests/UnitTests_tvOS/Doubles/XCUIElementDouble.h index 690a6b729..26ab59baf 100644 --- a/WebDriverAgentTests/UnitTests_tvOS/Doubles/XCUIElementDouble.h +++ b/WebDriverAgentTests/UnitTests_tvOS/Doubles/XCUIElementDouble.h @@ -29,6 +29,7 @@ @property (nonatomic, readwrite) NSUInteger wdIndex; @property (nonatomic, readwrite, getter=isWDVisible) BOOL wdVisible; @property (nonatomic, readwrite, getter=isWDAccessible) BOOL wdAccessible; +@property (nonatomic, readwrite, getter=isWDNativeAccessibilityElement) BOOL wdNativeAccessibilityElement; @property (nonatomic, readwrite, getter=isWDFocused) BOOL wdFocused; @property (nonatomic, readwrite, getter = isWDHittable) BOOL wdHittable; @property (copy, nonnull) NSArray *children; diff --git a/WebDriverAgentTests/UnitTests_tvOS/Doubles/XCUIElementDouble.m b/WebDriverAgentTests/UnitTests_tvOS/Doubles/XCUIElementDouble.m index cde9629e2..771913adc 100644 --- a/WebDriverAgentTests/UnitTests_tvOS/Doubles/XCUIElementDouble.m +++ b/WebDriverAgentTests/UnitTests_tvOS/Doubles/XCUIElementDouble.m @@ -24,6 +24,7 @@ - (id)init self.wdValue = @"magicValue"; self.wdVisible = YES; self.wdAccessible = YES; + self.wdNativeAccessibilityElement = YES; self.wdEnabled = YES; self.wdSelected = YES; self.wdHittable = YES; diff --git a/index.ts b/index.ts deleted file mode 100644 index 8a28be3e2..000000000 --- a/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { bundleWDASim } from './lib/check-dependencies'; -export { NoSessionProxy } from './lib/no-session-proxy'; -export { WebDriverAgent } from './lib/webdriveragent'; -export { WDA_BASE_URL, WDA_RUNNER_BUNDLE_ID, PROJECT_FILE } from './lib/constants'; -export { resetTestProcesses, BOOTSTRAP_PATH } from './lib/utils'; - -export * from './lib/types'; diff --git a/lib/check-dependencies.ts b/lib/check-dependencies.ts index 44c204d2c..d4d3390b5 100644 --- a/lib/check-dependencies.ts +++ b/lib/check-dependencies.ts @@ -5,21 +5,9 @@ import {WDA_SCHEME, SDK_SIMULATOR, WDA_RUNNER_APP} from './constants'; import {BOOTSTRAP_PATH} from './utils'; import type {XcodeBuild} from './xcodebuild'; -async function buildWDASim(): Promise { - const args = [ - '-project', - path.join(BOOTSTRAP_PATH, 'WebDriverAgent.xcodeproj'), - '-scheme', - WDA_SCHEME, - '-sdk', - SDK_SIMULATOR, - 'CODE_SIGN_IDENTITY=""', - 'CODE_SIGNING_REQUIRED="NO"', - 'GCC_TREAT_WARNINGS_AS_ERRORS=0', - ]; - await exec('xcodebuild', args); -} - +/** + * Ensure simulator WDA is built and return the resulting app bundle path. + */ export async function bundleWDASim(xcodebuild: XcodeBuild): Promise { const derivedDataPath = await xcodebuild.retrieveDerivedDataPath(); if (!derivedDataPath) { @@ -38,3 +26,18 @@ export async function bundleWDASim(xcodebuild: XcodeBuild): Promise { await buildWDASim(); return wdaBundlePath; } + +async function buildWDASim(): Promise { + const args = [ + '-project', + path.join(BOOTSTRAP_PATH, 'WebDriverAgent.xcodeproj'), + '-scheme', + WDA_SCHEME, + '-sdk', + SDK_SIMULATOR, + 'CODE_SIGN_IDENTITY=""', + 'CODE_SIGNING_REQUIRED="NO"', + 'GCC_TREAT_WARNINGS_AS_ERRORS=0', + ]; + await exec('xcodebuild', args); +} diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 000000000..0bb3f0806 --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,7 @@ +export {bundleWDASim} from './check-dependencies'; +export {NoSessionProxy} from './no-session-proxy'; +export {WebDriverAgent} from './webdriveragent'; +export {WDA_BASE_URL, WDA_RUNNER_APP, WDA_RUNNER_BUNDLE_ID, PROJECT_FILE} from './constants'; +export {resetTestProcesses, BOOTSTRAP_PATH} from './utils'; + +export * from './types'; diff --git a/lib/no-session-proxy.ts b/lib/no-session-proxy.ts index 963624de4..f6d3f42a2 100644 --- a/lib/no-session-proxy.ts +++ b/lib/no-session-proxy.ts @@ -11,13 +11,9 @@ export class NoSessionProxy extends JWProxy { url = '/'; } const proxyBase = `${this.scheme}://${this.server}:${this.port}${this.base}`; - let remainingUrl = ''; if (new RegExp('^/').test(url)) { - remainingUrl = url; - } else { - throw new Error(`Did not know what to do with url '${url}'`); + return proxyBase + url.replace(/\/$/, ''); // can't have trailing slashes } - remainingUrl = remainingUrl.replace(/\/$/, ''); // can't have trailing slashes - return proxyBase + remainingUrl; + throw new Error(`Did not know what to do with url '${url}'`); } } diff --git a/lib/types.ts b/lib/types.ts index 57524388d..207f3193b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,3 +1,5 @@ +import {type HTTPHeaders} from '@appium/types'; + // WebDriverAgentLib/Utilities/FBSettings.h export interface WDASettings { elementResponseAttribute?: string; @@ -18,7 +20,6 @@ export interface WDASettings { reduceMotion?: boolean; defaultActiveApplication?: string; activeAppDetectionPoint?: string; - includeNonModalElements?: boolean; defaultAlertAction?: 'accept' | 'dismiss'; acceptAlertButtonSelector?: string; dismissAlertButtonSelector?: string; @@ -42,7 +43,6 @@ export interface WDACapabilities { environment?: Record; eventloopIdleDelaySec?: number; shouldWaitForQuiescence?: boolean; - shouldUseTestManagerForVisibilityDetection?: boolean; maxTypingFrequency?: number; shouldUseSingletonTestManager?: boolean; waitForIdleTimeout?: number; @@ -78,6 +78,7 @@ export interface WebDriverAgentArgs { usePrebuiltWDA?: boolean; derivedDataPath?: string; mjpegServerPort?: number; + maxHttpRequestBodySize?: number; updatedWDABundleId?: string; wdaLaunchTimeout?: number; usePreinstalledWDA?: boolean; @@ -94,15 +95,69 @@ export interface WebDriverAgentArgs { resultBundleVersion?: string; reqBasePath?: string; launchTimeout?: number; + extraRequestHeaders?: HTTPHeaders; + hostOps?: WdaHostOps; } export interface AppleDevice { udid: string; - simctl?: any; - devicectl?: any; - /** @deprecated We'll stop supporting idb */ - idb?: any; - [key: string]: any; +} + +export type WdaStartupStrategyName = + | 'existing-url' + | 'simulator' + | 'real-device-xcodebuild' + | 'real-device-preinstalled'; + +export type WdaLaunchEnvironment = Record; + +export interface WdaLaunchOptions { + udid: string; + bundleId: string; + env: WdaLaunchEnvironment; + wdaLocalPort?: number; + wdaRemotePort: number; + platformName?: string; + platformVersion?: string; + timeoutMs: number; +} + +export interface WdaTerminateOptions { + udid: string; + bundleId: string; +} + +export interface WdaResetTestProcessesOptions { + udid: string; + isSimulator: boolean; +} + +export interface WdaCleanupObsoleteProcessesOptions { + udid: string; + port: string; + commandLineIncludes: string; +} + +export interface SimulatorHostOps { + launchPreinstalled(opts: WdaLaunchOptions): Promise; + terminate(opts: WdaTerminateOptions): Promise; + resetTestProcesses?(opts: WdaResetTestProcessesOptions): Promise; +} + +export interface RealDevicePreinstalledHostOps { + launchPreinstalled(opts: WdaLaunchOptions): Promise; + terminate(opts: WdaTerminateOptions): Promise; +} + +export interface RealDeviceXcodebuildHostOps { + resetTestProcesses?(opts: WdaResetTestProcessesOptions): Promise; + cleanupObsoleteProcesses?(opts: WdaCleanupObsoleteProcessesOptions): Promise; +} + +export interface WdaHostOps { + simulator?: SimulatorHostOps; + realDevicePreinstalled?: RealDevicePreinstalledHostOps; + realDeviceXcodebuild?: RealDeviceXcodebuildHostOps; } /** @@ -115,6 +170,35 @@ export interface DeviceInfo { platformName: string; } +/** Xcode build setting key/value pairs from `xcodebuild -showBuildSettings -json`. */ +export type XcodeBuildSettings = Record; + +/** A single target entry returned by `xcodebuild -showBuildSettings -json`. */ +export interface XcodeShowBuildSettingsEntry { + action: string; + buildSettings: XcodeBuildSettings; + target: string; +} + +export type WdaScheme = + | 'WebDriverAgentRunner' + | 'WebDriverAgentLib' + | 'WebDriverAgentRunner_tvOS' + | 'WebDriverAgentLib_tvOS'; + +export type WdaSdk = 'iphonesimulator' | 'iphoneos' | 'appletvsimulator' | 'appletvos'; + +export type WdaBuildConfiguration = 'Debug' | 'Release'; + +/** Options passed to {@link XcodeBuild.retrieveBuildSettings}. */ +export interface RetrieveBuildSettingsOptions { + scheme?: WdaScheme; + sdk?: WdaSdk; + configuration?: WdaBuildConfiguration; + /** `-destination` value (e.g. `id=` or a full destination specifier). */ + destination?: string; +} + export interface XcodeBuildArgs { realDevice: boolean; // Required agentPath: string; // Required @@ -138,8 +222,10 @@ export interface XcodeBuildArgs { updatedWDABundleId?: string; derivedDataPath?: string; mjpegServerPort?: number; + maxHttpRequestBodySize?: number; prebuildDelay?: number; allowProvisioningDeviceRegistration?: boolean; resultBundlePath?: string; resultBundleVersion?: string; + extraRequestHeaders?: HTTPHeaders; } diff --git a/lib/utils.ts b/lib/utils.ts deleted file mode 100644 index bf48f998c..000000000 --- a/lib/utils.ts +++ /dev/null @@ -1,370 +0,0 @@ -import {fs, plist} from '@appium/support'; -import {exec, SubProcess} from 'teen_process'; -import path, {dirname} from 'node:path'; -import {fileURLToPath} from 'node:url'; -import {log} from './logger'; -import _ from 'lodash'; -import {PLATFORM_NAME_TVOS} from './constants'; -import B from 'bluebird'; -import _fs from 'node:fs'; -import {waitForCondition} from 'asyncbox'; -import {arch} from 'node:os'; -import type {DeviceInfo} from './types'; - -// Get current filename - works in both CommonJS and ESM -const currentFilename = - typeof __filename !== 'undefined' - ? __filename - : fileURLToPath(new Function('return import.meta.url')()); - -const currentDirname = dirname(currentFilename); - -/** - * Calculates the path to the current module's root folder - * - * @returns {string} The full path to module root - * @throws {Error} If the current module root folder cannot be determined - */ -const getModuleRoot = _.memoize(function getModuleRoot(): string { - let currentDir = currentDirname; - let isAtFsRoot = false; - while (!isAtFsRoot) { - const manifestPath = path.join(currentDir, 'package.json'); - try { - if ( - _fs.existsSync(manifestPath) && - JSON.parse(_fs.readFileSync(manifestPath, 'utf8')).name === 'appium-webdriveragent' - ) { - return currentDir; - } - } catch {} - currentDir = path.dirname(currentDir); - isAtFsRoot = currentDir.length <= path.dirname(currentDir).length; - } - throw new Error('Cannot find the root folder of the appium-webdriveragent Node.js module'); -}); - -export const BOOTSTRAP_PATH = getModuleRoot(); - -export async function killAppUsingPattern(pgrepPattern: string): Promise { - const signals = [2, 15, 9]; - for (const signal of signals) { - const matchedPids = await getPIDsUsingPattern(pgrepPattern); - if (_.isEmpty(matchedPids)) { - return; - } - const args = [`-${signal}`, ...matchedPids]; - try { - await exec('kill', args); - } catch (err: any) { - log.debug(`kill ${args.join(' ')} -> ${err.message}`); - } - if (signal === _.last(signals)) { - // there is no need to wait after SIGKILL - return; - } - try { - await waitForCondition( - async () => { - const pidCheckPromises = matchedPids.map((pid) => - exec('kill', ['-0', pid]) - // the process is still alive - .then(() => false) - // the process is dead - .catch(() => true), - ); - return (await B.all(pidCheckPromises)).every((x) => x === true); - }, - { - waitMs: 1000, - intervalMs: 100, - }, - ); - return; - } catch { - // try the next signal - } - } -} - -/** - * Return true if the platformName is tvOS - * @param platformName The name of the platorm - * @returns Return true if the platformName is tvOS - */ -export function isTvOS(platformName: string): boolean { - return _.toLower(platformName) === _.toLower(PLATFORM_NAME_TVOS); -} - -export async function setRealDeviceSecurity( - keychainPath: string, - keychainPassword: string, -): Promise { - log.debug('Setting security for iOS device'); - await exec('security', ['-v', 'list-keychains', '-s', keychainPath]); - await exec('security', ['-v', 'unlock-keychain', '-p', keychainPassword, keychainPath]); - await exec('security', ['set-keychain-settings', '-t', '3600', '-l', keychainPath]); -} - -/** - * Arguments for setting xctestrun file - */ -export interface XctestrunFileArgs { - deviceInfo: DeviceInfo; - sdkVersion: string; - bootstrapPath: string; - wdaRemotePort: number | string; - wdaBindingIP?: string; -} - -/** - * Creates xctestrun file per device & platform version. - * We expects to have WebDriverAgentRunner_iphoneos${sdkVersion|platformVersion}-arm64.xctestrun for real device - * and WebDriverAgentRunner_iphonesimulator${sdkVersion|platformVersion}-${x86_64|arm64}.xctestrun for simulator located @bootstrapPath - * Newer Xcode (Xcode 10.0 at least) generate xctestrun file following sdkVersion. - * e.g. Xcode which has iOS SDK Version 12.2 on an intel Mac host machine generates WebDriverAgentRunner_iphonesimulator.2-x86_64.xctestrun - * even if the cap has platform version 11.4 - * - * @param args - * @return returns xctestrunFilePath for given device - * @throws if WebDriverAgentRunner_iphoneos${sdkVersion|platformVersion}-arm64.xctestrun for real device - * or WebDriverAgentRunner_iphonesimulator${sdkVersion|platformVersion}-x86_64.xctestrun for simulator is not found @bootstrapPath, - * then it will throw a file not found exception - */ -export async function setXctestrunFile(args: XctestrunFileArgs): Promise { - const {deviceInfo, sdkVersion, bootstrapPath, wdaRemotePort, wdaBindingIP} = args; - const xctestrunFilePath = await getXctestrunFilePath(deviceInfo, sdkVersion, bootstrapPath); - const xctestRunContent = await plist.parsePlistFile(xctestrunFilePath); - const updateWDAPort = getAdditionalRunContent( - deviceInfo.platformName, - wdaRemotePort, - wdaBindingIP, - ); - const newXctestRunContent = _.merge(xctestRunContent, updateWDAPort); - await plist.updatePlistFile(xctestrunFilePath, newXctestRunContent, true); - - return xctestrunFilePath; -} - -/** - * Return the WDA object which appends existing xctest runner content - * @param platformName - The name of the platform - * @param wdaRemotePort - The remote port number - * @param wdaBindingIP - The IP address to bind to. If not given, it binds to all interfaces. - * @return returns a runner object which has USE_PORT and optionally USE_IP - */ -export function getAdditionalRunContent( - platformName: string, - wdaRemotePort: number | string, - wdaBindingIP?: string, -): Record { - const runner = `WebDriverAgentRunner${isTvOS(platformName) ? '_tvOS' : ''}`; - return { - [runner]: { - EnvironmentVariables: { - // USE_PORT must be 'string' - USE_PORT: `${wdaRemotePort}`, - ...(wdaBindingIP ? {USE_IP: wdaBindingIP} : {}), - }, - }, - }; -} - -/** - * Return the path of xctestrun if it exists - * @param deviceInfo - * @param sdkVersion - The Xcode SDK version of OS. - * @param bootstrapPath - The folder path containing xctestrun file. - */ -export async function getXctestrunFilePath( - deviceInfo: DeviceInfo, - sdkVersion: string, - bootstrapPath: string, -): Promise { - // First try the SDK path, for Xcode 10 (at least) - const sdkBased: [string, string] = [ - path.resolve(bootstrapPath, `${deviceInfo.udid}_${sdkVersion}.xctestrun`), - sdkVersion, - ]; - // Next try Platform path, for earlier Xcode versions - const platformBased: [string, string] = [ - path.resolve(bootstrapPath, `${deviceInfo.udid}_${deviceInfo.platformVersion}.xctestrun`), - deviceInfo.platformVersion, - ]; - - for (const [filePath, version] of [sdkBased, platformBased]) { - if (await fs.exists(filePath)) { - log.info(`Using '${filePath}' as xctestrun file`); - return filePath; - } - const originalXctestrunFile = path.resolve( - bootstrapPath, - getXctestrunFileName(deviceInfo, version), - ); - if (await fs.exists(originalXctestrunFile)) { - // If this is first time run for given device, then first generate xctestrun file for device. - // We need to have a xctestrun file **per device** because we cant not have same wda port for all devices. - await fs.copyFile(originalXctestrunFile, filePath); - log.info(`Using '${filePath}' as xctestrun file copied by '${originalXctestrunFile}'`); - return filePath; - } - } - - throw new Error( - `If you are using 'useXctestrunFile' capability then you ` + - `need to have a xctestrun file (expected: ` + - `'${path.resolve(bootstrapPath, getXctestrunFileName(deviceInfo, sdkVersion))}')`, - ); -} - -/** - * Return the name of xctestrun file - * @param deviceInfo - * @param version - The Xcode SDK version of OS. - * @return returns xctestrunFilePath for given device - */ -export function getXctestrunFileName(deviceInfo: DeviceInfo, version: string): string { - const archSuffix = deviceInfo.isRealDevice - ? `os${version}-arm64` - : `simulator${version}-${arch() === 'arm64' ? 'arm64' : 'x86_64'}`; - return `WebDriverAgentRunner_${isTvOS(deviceInfo.platformName) ? 'tvOS_appletv' : 'iphone'}${archSuffix}.xctestrun`; -} - -/** - * Ensures the process is killed after the timeout - */ -export async function killProcess( - name: string, - proc: SubProcess | null | undefined, -): Promise { - if (!proc || !proc.isRunning) { - return; - } - - log.info(`Shutting down '${name}' process (pid '${proc.proc?.pid}')`); - - log.info(`Sending 'SIGTERM'...`); - try { - await proc.stop('SIGTERM', 1000); - return; - } catch (err: any) { - if (!err.message.includes(`Process didn't end after`)) { - throw err; - } - log.debug(`${name} process did not end in a timely fashion: '${err.message}'.`); - } - - log.info(`Sending 'SIGKILL'...`); - try { - await proc.stop('SIGKILL'); - } catch (err: any) { - if (err.message.includes('not currently running')) { - // the process ended but for some reason we were not informed - return; - } - throw err; - } -} - -/** - * Generate a random integer in range [low, high). `low` is inclusive and `high` is exclusive. - */ -export function randomInt(low: number, high: number): number { - return Math.floor(Math.random() * (high - low) + low); -} - -/** - * Retrieves WDA upgrade timestamp. The manifest only gets modified on package upgrade. - */ -export async function getWDAUpgradeTimestamp(): Promise { - const packageManifest = path.resolve(getModuleRoot(), 'package.json'); - if (!(await fs.exists(packageManifest))) { - return null; - } - const {mtime} = await fs.stat(packageManifest); - return mtime.getTime(); -} - -/** - * Kills running XCTest processes for the particular device. - */ -export async function resetTestProcesses(udid: string, isSimulator: boolean): Promise { - const processPatterns = [`xcodebuild.*${udid}`]; - if (isSimulator) { - processPatterns.push(`${udid}.*XCTRunner`); - // The pattern to find in case idb was used - processPatterns.push(`xctest.*${udid}`); - } - log.debug(`Killing running processes '${processPatterns.join(', ')}' for the device ${udid}...`); - await B.all(processPatterns.map(killAppUsingPattern)); -} - -/** - * Get the IDs of processes listening on the particular system port. - * It is also possible to apply additional filtering based on the - * process command line. - * - * @param port - The port number. - * @param filteringFunc - Optional lambda function, which - * receives command line string of the particular process - * listening on given port, and is expected to return - * either true or false to include/exclude the corresponding PID - * from the resulting array. - * @returns - the list of matched process ids. - */ -export async function getPIDsListeningOnPort( - port: string | number, - filteringFunc: ((cmdline: string) => boolean | Promise) | null = null, -): Promise { - const result: string[] = []; - try { - // This only works since Mac OS X El Capitan - const {stdout} = await exec('lsof', ['-ti', `tcp:${port}`]); - result.push(...stdout.trim().split(/\n+/)); - } catch (e: any) { - if (e.code !== 1) { - // code 1 means no processes. Other errors need reporting - log.debug(`Error getting processes listening on port '${port}': ${e.stderr || e.message}`); - } - return result; - } - - if (!_.isFunction(filteringFunc)) { - return result; - } - return await B.filter(result, async (pid) => { - let stdout: string; - try { - ({stdout} = await exec('ps', ['-p', pid, '-o', 'command'])); - } catch (e: any) { - if (e.code === 1) { - // The process does not exist anymore, there's nothing to filter - return false; - } - throw e; - } - return await filteringFunc(stdout); - }); -} - -// Private functions - -async function getPIDsUsingPattern(pattern: string): Promise { - const args = [ - '-if', // case insensitive, full cmdline match - pattern, - ]; - try { - const {stdout} = await exec('pgrep', args); - return stdout - .split(/\s+/) - .map((x) => parseInt(x, 10)) - .filter(_.isInteger) - .map((x) => `${x}`); - } catch (err: any) { - log.debug( - `'pgrep ${args.join(' ')}' didn't detect any matching processes. Return code: ${err.code}`, - ); - return []; - } -} diff --git a/lib/utils/index.ts b/lib/utils/index.ts new file mode 100644 index 000000000..63783a3a8 --- /dev/null +++ b/lib/utils/index.ts @@ -0,0 +1,20 @@ +import {getWDAUpgradeTimestamp as getWDAUpgradeTimestampImpl} from './module'; + +export {BOOTSTRAP_PATH} from './module'; +export {isTvOS} from './platform'; +export {getPIDsListeningOnPort, killAppUsingPattern, resetTestProcesses} from './processes'; +export {setRealDeviceSecurity} from './security'; +export { + getAdditionalRunContent, + getXctestrunFileName, + getXctestrunFilePath, + setXctestrunFile, +} from './xctestrun'; +export type {XctestrunFileArgs} from './xctestrun'; + +/** + * Retrieves WDA upgrade timestamp. The manifest only gets modified on package upgrade. + */ +export async function getWDAUpgradeTimestamp(): Promise { + return await getWDAUpgradeTimestampImpl(); +} diff --git a/lib/utils/module.ts b/lib/utils/module.ts new file mode 100644 index 000000000..3e1cc097c --- /dev/null +++ b/lib/utils/module.ts @@ -0,0 +1,29 @@ +import {fs, node as supportNode} from '@appium/support'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +// Get current filename - works in both CommonJS and ESM +const currentFilename = + typeof __filename !== 'undefined' + ? __filename + : fileURLToPath(new Function('return import.meta.url')()); + +const moduleRoot = supportNode.getModuleRootSync('appium-webdriveragent', currentFilename); + +if (!moduleRoot) { + throw new Error('Cannot find the root folder of the appium-webdriveragent Node.js module'); +} + +export const BOOTSTRAP_PATH = moduleRoot; + +/** + * Retrieves WDA upgrade timestamp. The manifest only gets modified on package upgrade. + */ +export async function getWDAUpgradeTimestamp(): Promise { + const packageManifest = path.resolve(BOOTSTRAP_PATH, 'package.json'); + if (!(await fs.exists(packageManifest))) { + return null; + } + const {mtime} = await fs.stat(packageManifest); + return mtime.getTime(); +} diff --git a/lib/utils/platform.ts b/lib/utils/platform.ts new file mode 100644 index 000000000..0afd34c07 --- /dev/null +++ b/lib/utils/platform.ts @@ -0,0 +1,10 @@ +import {PLATFORM_NAME_TVOS} from '../constants'; + +/** + * Return true if the platformName is tvOS + * @param platformName The name of the platform + * @returns Return true if the platformName is tvOS + */ +export function isTvOS(platformName: string): boolean { + return platformName?.toLowerCase() === PLATFORM_NAME_TVOS.toLowerCase(); +} diff --git a/lib/utils/processes.ts b/lib/utils/processes.ts new file mode 100644 index 000000000..1b47d2888 --- /dev/null +++ b/lib/utils/processes.ts @@ -0,0 +1,135 @@ +import {waitForCondition} from 'asyncbox'; +import {exec} from 'teen_process'; +import {log} from '../logger'; + +/** + * Find and terminate all processes matching the given pgrep pattern. + */ +export async function killAppUsingPattern(pgrepPattern: string): Promise { + const signals = [2, 15, 9]; + for (const signal of signals) { + const matchedPids = await getPIDsUsingPattern(pgrepPattern); + if (matchedPids.length === 0) { + return; + } + const args = [`-${signal}`, ...matchedPids]; + try { + await exec('kill', args); + } catch (err: any) { + log.debug(`kill ${args.join(' ')} -> ${err.message}`); + } + if (signal === signals[signals.length - 1]) { + // there is no need to wait after SIGKILL + return; + } + try { + await waitForCondition( + async () => { + const pidCheckPromises = matchedPids.map(async (pid) => { + try { + await exec('kill', ['-0', pid]); + // the process is still alive + return false; + } catch { + // the process is dead + return true; + } + }); + return (await Promise.all(pidCheckPromises)).every((x) => x === true); + }, + { + waitMs: 1000, + intervalMs: 100, + }, + ); + return; + } catch { + // try the next signal + } + } +} + +/** + * Kills running XCTest processes for the particular device. + */ +export async function resetTestProcesses(udid: string, isSimulator: boolean): Promise { + const processPatterns = [`xcodebuild.*${udid}`]; + if (isSimulator) { + processPatterns.push(`${udid}.*XCTRunner`); + // Some XCTest launches might not include xcodebuild in their command line + processPatterns.push(`xctest.*${udid}`); + } + log.debug(`Killing running processes '${processPatterns.join(', ')}' for the device ${udid}...`); + await Promise.all(processPatterns.map(killAppUsingPattern)); +} + +/** + * Get the IDs of processes listening on the particular system port. + * It is also possible to apply additional filtering based on the + * process command line. + * + * @param port - The port number. + * @param filteringFunc - Optional lambda function, which + * receives command line string of the particular process + * listening on given port, and is expected to return + * either true or false to include/exclude the corresponding PID + * from the resulting array. + * @returns - the list of matched process ids. + */ +export async function getPIDsListeningOnPort( + port: string | number, + filteringFunc: ((cmdline: string) => boolean | Promise) | null = null, +): Promise { + const result: string[] = []; + try { + // This only works since Mac OS X El Capitan + const {stdout} = await exec('lsof', ['-ti', `tcp:${port}`]); + result.push(...stdout.trim().split(/\n+/)); + } catch (e: any) { + if (e.code !== 1) { + // code 1 means no processes. Other errors need reporting + log.debug(`Error getting processes listening on port '${port}': ${e.stderr || e.message}`); + } + return result; + } + + if (typeof filteringFunc !== 'function') { + return result; + } + const filtered = await Promise.all( + result.map(async (pid) => { + let stdout: string; + try { + ({stdout} = await exec('ps', ['-p', pid, '-o', 'command'])); + } catch (e: any) { + if (e.code === 1) { + // The process does not exist anymore, there's nothing to filter + return null; + } + throw e; + } + return (await filteringFunc(stdout)) ? pid : null; + }), + ); + return filtered.filter((pid): pid is string => Boolean(pid)); +} + +async function getPIDsUsingPattern(pattern: string): Promise { + const args = [ + '-if', // case insensitive, full cmdline match + pattern, + ]; + try { + const {stdout} = await exec('pgrep', args); + return stdout + .split(/\s+/) + .map((x) => parseInt(x, 10)) + .filter(Number.isInteger) + .map((x) => `${x}`); + } catch (err: any) { + log.debug( + `'pgrep ${args.join(' ')}' didn't detect any matching processes. Return code: ${err.code}`, + ); + return []; + } +} diff --git a/lib/utils/security.ts b/lib/utils/security.ts new file mode 100644 index 000000000..9ad067287 --- /dev/null +++ b/lib/utils/security.ts @@ -0,0 +1,15 @@ +import {exec} from 'teen_process'; +import {log} from '../logger'; + +/** + * Configure keychain access required for real-device code signing. + */ +export async function setRealDeviceSecurity( + keychainPath: string, + keychainPassword: string, +): Promise { + log.debug('Setting security for iOS device'); + await exec('security', ['-v', 'list-keychains', '-s', keychainPath]); + await exec('security', ['-v', 'unlock-keychain', '-p', keychainPassword, keychainPath]); + await exec('security', ['set-keychain-settings', '-t', '3600', '-l', keychainPath]); +} diff --git a/lib/utils/xctestrun.ts b/lib/utils/xctestrun.ts new file mode 100644 index 000000000..b4af43b10 --- /dev/null +++ b/lib/utils/xctestrun.ts @@ -0,0 +1,160 @@ +import {fs, plist, util} from '@appium/support'; +import path from 'node:path'; +import {arch} from 'node:os'; +import {log} from '../logger'; +import type {DeviceInfo} from '../types'; +import {isTvOS} from './platform'; + +/** + * Arguments for setting xctestrun file + */ +export interface XctestrunFileArgs { + deviceInfo: DeviceInfo; + sdkVersion: string; + bootstrapPath: string; + wdaRemotePort: number | string; + wdaBindingIP?: string; + maxHttpRequestBodySize?: number | string; +} + +/** + * Creates xctestrun file per device & platform version. + * We expect to have WebDriverAgentRunner_iphoneos${sdkVersion|platformVersion}-arm64.xctestrun for real device + * and WebDriverAgentRunner_iphonesimulator${sdkVersion|platformVersion}-${x86_64|arm64}.xctestrun for simulator located @bootstrapPath + * Newer Xcode (Xcode 10.0 at least) generates xctestrun file following sdkVersion. + * e.g. Xcode which has iOS SDK Version 12.2 on an intel Mac host machine generates WebDriverAgentRunner_iphonesimulator.2-x86_64.xctestrun + * even if the cap has platform version 11.4 + * + * @param args + * @return returns xctestrunFilePath for given device + * @throws if WebDriverAgentRunner_iphoneos${sdkVersion|platformVersion}-arm64.xctestrun for real device + * or WebDriverAgentRunner_iphonesimulator${sdkVersion|platformVersion}-x86_64.xctestrun for simulator is not found @bootstrapPath, + * then it will throw a file not found exception + */ +export async function setXctestrunFile(args: XctestrunFileArgs): Promise { + const { + deviceInfo, + sdkVersion, + bootstrapPath, + wdaRemotePort, + wdaBindingIP, + maxHttpRequestBodySize, + } = args; + const xctestrunFilePath = await getXctestrunFilePath(deviceInfo, sdkVersion, bootstrapPath); + const xctestRunContent = await plist.parsePlistFile(xctestrunFilePath); + const updateWDAPort = getAdditionalRunContent( + deviceInfo.platformName, + wdaRemotePort, + wdaBindingIP, + maxHttpRequestBodySize, + ); + const newXctestRunContent = mergeObjects(xctestRunContent, updateWDAPort); + await plist.updatePlistFile(xctestrunFilePath, newXctestRunContent, true); + + return xctestrunFilePath; +} + +/** + * Return the WDA object which appends existing xctest runner content + * @param platformName - The name of the platform + * @param wdaRemotePort - The remote port number + * @param wdaBindingIP - The IP address to bind to. If not given, it binds to all interfaces. + * @param maxHttpRequestBodySize - The maximum HTTP request body size in bytes. + * @return returns a runner object which has USE_PORT and optionally USE_IP + */ +export function getAdditionalRunContent( + platformName: string, + wdaRemotePort: number | string, + wdaBindingIP?: string, + maxHttpRequestBodySize?: number | string, +): Record { + const runner = `WebDriverAgentRunner${isTvOS(platformName) ? '_tvOS' : ''}`; + return { + [runner]: { + EnvironmentVariables: { + // USE_PORT must be 'string' + USE_PORT: `${wdaRemotePort}`, + ...(wdaBindingIP ? {USE_IP: wdaBindingIP} : {}), + ...(maxHttpRequestBodySize + ? {MAX_HTTP_REQUEST_BODY_SIZE: `${maxHttpRequestBodySize}`} + : {}), + }, + }, + }; +} + +/** + * Return the path of xctestrun if it exists + * @param deviceInfo + * @param sdkVersion - The Xcode SDK version of OS. + * @param bootstrapPath - The folder path containing xctestrun file. + */ +export async function getXctestrunFilePath( + deviceInfo: DeviceInfo, + sdkVersion: string, + bootstrapPath: string, +): Promise { + // First try the SDK path, for Xcode 10 (at least) + const sdkBased: [string, string] = [ + path.resolve(bootstrapPath, `${deviceInfo.udid}_${sdkVersion}.xctestrun`), + sdkVersion, + ]; + // Next try Platform path, for earlier Xcode versions + const platformBased: [string, string] = [ + path.resolve(bootstrapPath, `${deviceInfo.udid}_${deviceInfo.platformVersion}.xctestrun`), + deviceInfo.platformVersion, + ]; + + for (const [filePath, version] of [sdkBased, platformBased]) { + if (await fs.exists(filePath)) { + log.info(`Using '${filePath}' as xctestrun file`); + return filePath; + } + const originalXctestrunFile = path.resolve( + bootstrapPath, + getXctestrunFileName(deviceInfo, version), + ); + if (await fs.exists(originalXctestrunFile)) { + // If this is first time run for given device, then first generate xctestrun file for device. + // We need to have a xctestrun file **per device** because we cannot have same wda port for all devices. + await fs.copyFile(originalXctestrunFile, filePath); + log.info(`Using '${filePath}' as xctestrun file copied by '${originalXctestrunFile}'`); + return filePath; + } + } + + throw new Error( + `If you are using 'useXctestrunFile' capability then you ` + + `need to have a xctestrun file (expected: ` + + `'${path.resolve(bootstrapPath, getXctestrunFileName(deviceInfo, sdkVersion))}')`, + ); +} + +/** + * Return the name of xctestrun file + * @param deviceInfo + * @param version - The Xcode SDK version of OS. + * @return returns xctestrunFilePath for given device + */ +export function getXctestrunFileName(deviceInfo: DeviceInfo, version: string): string { + const archSuffix = deviceInfo.isRealDevice + ? `os${version}-arm64` + : `simulator${version}-${arch() === 'arm64' ? 'arm64' : 'x86_64'}`; + return `WebDriverAgentRunner_${isTvOS(deviceInfo.platformName) ? 'tvOS_appletv' : 'iphone'}${archSuffix}.xctestrun`; +} + +function mergeObjects, U extends Record>( + target: T, + source: U, +): T & U { + const output: Record = {...target}; + for (const [key, sourceValue] of Object.entries(source)) { + const targetValue = output[key]; + if (util.isPlainObject(targetValue) && util.isPlainObject(sourceValue)) { + output[key] = mergeObjects(targetValue, sourceValue); + continue; + } + output[key] = sourceValue; + } + return output as T & U; +} diff --git a/lib/wda-strategies.ts b/lib/wda-strategies.ts new file mode 100644 index 000000000..68b43a1e9 --- /dev/null +++ b/lib/wda-strategies.ts @@ -0,0 +1,340 @@ +import {exec} from 'teen_process'; +import {fs} from '@appium/support'; +import type {AppiumLogger, StringRecord} from '@appium/types'; +import {getPIDsListeningOnPort, resetTestProcesses} from './utils'; +import type {NoSessionProxy} from './no-session-proxy'; +import type {XcodeBuild} from './xcodebuild'; +import type { + AppleDevice, + RealDevicePreinstalledHostOps, + RealDeviceXcodebuildHostOps, + SimulatorHostOps, + WdaHostOps, + WdaLaunchEnvironment, + WdaStartupStrategyName, +} from './types'; + +const WDA_AGENT_PORT = 8100; +const HOST_OPS_REQUIRED_MESSAGE = + 'Host operations must be provided to launch or terminate preinstalled WebDriverAgent'; + +export interface WdaStartupStrategy { + readonly name: WdaStartupStrategyName; + launch(sessionId: string): Promise; + quit(): Promise; +} + +export interface WdaStartupStrategyContext { + readonly argsWebDriverAgentUrl?: string; + readonly webDriverAgentUrl?: string; + readonly usePreinstalledWDA?: boolean; + readonly useXctestrunFile?: boolean; + readonly usePrebuiltWDA?: boolean; + readonly prebuildWDA?: boolean; + readonly isRealDevice: boolean; + readonly device: AppleDevice; + readonly agentPath: string; + readonly bootstrapPath: string; + readonly bundleIdForXctest: string; + readonly wdaLocalPort?: number; + readonly wdaRemotePort: number; + readonly wdaBindingIP?: string; + readonly wdaLaunchTimeout: number; + readonly mjpegServerPort?: number; + readonly maxHttpRequestBodySize?: number; + readonly platformName?: string; + readonly platformVersion?: string; + readonly log: AppiumLogger; + readonly hostOps: Required; + setWebDriverAgentUrl(value?: string): void; + setUrl(value: string): void; + setupProxies(sessionId: string): void; + getStatus(timeoutMs?: number): Promise; + cleanupProjectIfFresh(): Promise; + xcodebuild(): XcodeBuild; + noSessionProxy(): NoSessionProxy; + setStarted(started: boolean): void; +} + +class ExistingWdaUrlStrategy implements WdaStartupStrategy { + readonly name = 'existing-url' as const; + + constructor(private readonly ctx: WdaStartupStrategyContext) {} + + async launch(sessionId: string): Promise { + this.ctx.log.info(`Using provided WebdriverAgent at '${this.ctx.webDriverAgentUrl}'`); + this.ctx.setUrl(this.ctx.webDriverAgentUrl as string); + this.ctx.setupProxies(sessionId); + return await this.ctx.getStatus(); + } + + async quit(): Promise { + this.ctx.log.debug( + 'Stopping neither xcodebuild nor XCTest session since WDA lifecycle is not managed by this driver', + ); + } +} + +class SimulatorWdaStrategy implements WdaStartupStrategy { + readonly name = 'simulator' as const; + + constructor(private readonly ctx: WdaStartupStrategyContext) {} + + async launch(sessionId: string): Promise { + if (this.ctx.usePreinstalledWDA) { + return await launchPreinstalled(this.ctx, this.ctx.hostOps.simulator, sessionId); + } + return await launchWithXcodebuild(this.ctx, sessionId); + } + + async quit(): Promise { + if (this.ctx.usePreinstalledWDA) { + await terminatePreinstalled(this.ctx, this.ctx.hostOps.simulator); + return; + } + await quitXcodebuild(this.ctx); + } +} + +class RealDeviceXcodebuildStrategy implements WdaStartupStrategy { + readonly name = 'real-device-xcodebuild' as const; + + constructor(private readonly ctx: WdaStartupStrategyContext) {} + + async launch(sessionId: string): Promise { + return await launchWithXcodebuild(this.ctx, sessionId); + } + + async quit(): Promise { + await quitXcodebuild(this.ctx); + } +} + +class RealDevicePreinstalledStrategy implements WdaStartupStrategy { + readonly name = 'real-device-preinstalled' as const; + + constructor(private readonly ctx: WdaStartupStrategyContext) {} + + async launch(sessionId: string): Promise { + return await launchPreinstalled(this.ctx, this.ctx.hostOps.realDevicePreinstalled, sessionId); + } + + async quit(): Promise { + await terminatePreinstalled(this.ctx, this.ctx.hostOps.realDevicePreinstalled); + } +} + +/** + * Selects the WDA startup strategy for the provided launch arguments. + */ +export function selectWdaStartupStrategyName(args: { + realDevice?: boolean; + webDriverAgentUrl?: string; + usePreinstalledWDA?: boolean; +}): WdaStartupStrategyName { + if (args.webDriverAgentUrl) { + return 'existing-url'; + } + if (!args.realDevice) { + return 'simulator'; + } + if (args.usePreinstalledWDA) { + return 'real-device-preinstalled'; + } + return 'real-device-xcodebuild'; +} + +/** + * Creates a WDA startup strategy for the current facade state. + */ +export function createWdaStartupStrategy(ctx: WdaStartupStrategyContext): WdaStartupStrategy { + const startupStrategy = selectWdaStartupStrategyName({ + realDevice: ctx.isRealDevice, + webDriverAgentUrl: ctx.webDriverAgentUrl, + usePreinstalledWDA: ctx.usePreinstalledWDA, + }); + switch (startupStrategy) { + case 'existing-url': + return new ExistingWdaUrlStrategy(ctx); + case 'simulator': + return new SimulatorWdaStrategy(ctx); + case 'real-device-preinstalled': + return new RealDevicePreinstalledStrategy(ctx); + case 'real-device-xcodebuild': + return new RealDeviceXcodebuildStrategy(ctx); + default: + throw new Error(`Unknown WDA startup strategy: ${startupStrategy}`); + } +} + +/** + * Creates default host operations for flows the package can own directly. + */ +export function createDefaultWdaHostOps(): Required { + return { + simulator: createDefaultSimulatorWdaHostOps(), + realDevicePreinstalled: createDefaultRealDevicePreinstalledHostOps(), + realDeviceXcodebuild: createDefaultRealDeviceXcodebuildHostOps(), + }; +} + +/** + * Creates default simulator host operations. + */ +export function createDefaultSimulatorWdaHostOps(): SimulatorHostOps { + return { + async launchPreinstalled() { + throw new Error(HOST_OPS_REQUIRED_MESSAGE); + }, + async terminate() { + throw new Error(HOST_OPS_REQUIRED_MESSAGE); + }, + async resetTestProcesses({udid, isSimulator}) { + await resetTestProcesses(udid, isSimulator); + }, + }; +} + +/** + * Creates default real-device preinstalled host operations. + */ +export function createDefaultRealDevicePreinstalledHostOps(): RealDevicePreinstalledHostOps { + return { + async launchPreinstalled() { + throw new Error(HOST_OPS_REQUIRED_MESSAGE); + }, + async terminate() { + throw new Error(HOST_OPS_REQUIRED_MESSAGE); + }, + }; +} + +/** + * Creates default real-device xcodebuild host operations. + */ +export function createDefaultRealDeviceXcodebuildHostOps(): RealDeviceXcodebuildHostOps { + return { + async resetTestProcesses({udid, isSimulator}) { + await resetTestProcesses(udid, isSimulator); + }, + async cleanupObsoleteProcesses({udid, port, commandLineIncludes}) { + const obsoletePids = await getPIDsListeningOnPort( + port, + (cmdLine) => + cmdLine.includes(commandLineIncludes) && + !cmdLine.toLowerCase().includes(udid.toLowerCase()), + ); + + if (obsoletePids.length > 0) { + await exec('kill', obsoletePids); + } + }, + }; +} + +async function launchWithXcodebuild( + ctx: WdaStartupStrategyContext, + sessionId: string, +): Promise { + ctx.log.info('Launching WebDriverAgent on the device'); + + ctx.setupProxies(sessionId); + + if (!ctx.useXctestrunFile && !(await fs.exists(ctx.agentPath))) { + throw new Error( + `Trying to use WebDriverAgent project at '${ctx.agentPath}' but the ` + 'file does not exist', + ); + } + + if (ctx.useXctestrunFile || ctx.usePrebuiltWDA) { + ctx.log.info('Skipped WDA project cleanup according to the provided capabilities'); + } else { + await ctx.cleanupProjectIfFresh(); + } + + const resetTestProcesses = ctx.isRealDevice + ? ctx.hostOps.realDeviceXcodebuild.resetTestProcesses + : ctx.hostOps.simulator.resetTestProcesses; + await resetTestProcesses?.({ + udid: ctx.device.udid, + isSimulator: !ctx.isRealDevice, + }); + + const xcodebuild = ctx.xcodebuild(); + await xcodebuild.init(ctx.noSessionProxy()); + + if (ctx.prebuildWDA) { + await xcodebuild.prebuild(); + } + return (await xcodebuild.start()) as StringRecord | null; +} + +async function launchPreinstalled( + ctx: WdaStartupStrategyContext, + hostOps: SimulatorHostOps | RealDevicePreinstalledHostOps, + sessionId: string, +): Promise { + const xctestEnv = createPreinstalledWdaEnvironment(ctx); + ctx.log.info('Launching WebDriverAgent on the device without xcodebuild'); + await hostOps.launchPreinstalled({ + udid: ctx.device.udid, + bundleId: ctx.bundleIdForXctest, + env: xctestEnv, + wdaLocalPort: ctx.wdaLocalPort, + wdaRemotePort: ctx.wdaRemotePort, + platformName: ctx.platformName, + platformVersion: ctx.platformVersion, + timeoutMs: ctx.wdaLaunchTimeout, + }); + + ctx.setupProxies(sessionId); + let status: StringRecord | null; + try { + status = await ctx.getStatus(ctx.wdaLaunchTimeout); + } catch { + throw new Error( + `Failed to start the preinstalled WebDriverAgent in ${ctx.wdaLaunchTimeout} ms. ` + + `The WebDriverAgent might not be properly built or the device might be locked. ` + + `The 'appium:wdaLaunchTimeout' capability modifies the timeout.`, + ); + } + ctx.setStarted(true); + return status; +} + +async function terminatePreinstalled( + ctx: WdaStartupStrategyContext, + hostOps: SimulatorHostOps | RealDevicePreinstalledHostOps, +): Promise { + ctx.log.info('Stopping the XCTest session'); + try { + await hostOps.terminate({ + udid: ctx.device.udid, + bundleId: ctx.bundleIdForXctest, + }); + } catch (e: any) { + ctx.log.warn(e.message); + } +} + +async function quitXcodebuild(ctx: WdaStartupStrategyContext): Promise { + ctx.log.info('Shutting down sub-processes'); + await ctx.xcodebuild().quit(); +} + +function createPreinstalledWdaEnvironment(ctx: WdaStartupStrategyContext): WdaLaunchEnvironment { + const xctestEnv: WdaLaunchEnvironment = { + USE_PORT: ctx.wdaLocalPort || WDA_AGENT_PORT, + WDA_PRODUCT_BUNDLE_IDENTIFIER: ctx.bundleIdForXctest, + }; + if (ctx.mjpegServerPort) { + xctestEnv.MJPEG_SERVER_PORT = ctx.mjpegServerPort; + } + if (ctx.wdaBindingIP) { + xctestEnv.USE_IP = ctx.wdaBindingIP; + } + if (ctx.maxHttpRequestBodySize) { + xctestEnv.MAX_HTTP_REQUEST_BODY_SIZE = ctx.maxHttpRequestBodySize; + } + return xctestEnv; +} diff --git a/lib/webdriveragent.ts b/lib/webdriveragent.ts index 95daacf4b..2e7d97b4d 100644 --- a/lib/webdriveragent.ts +++ b/lib/webdriveragent.ts @@ -1,74 +1,74 @@ import {waitForCondition} from 'asyncbox'; -import _ from 'lodash'; import path from 'node:path'; -import url from 'node:url'; -import B from 'bluebird'; import {JWProxy} from '@appium/base-driver'; -import {fs, util, plist} from '@appium/support'; +import {fs, util} from '@appium/support'; import type {AppiumLogger, StringRecord} from '@appium/types'; import {log as defaultLogger} from './logger'; import {NoSessionProxy} from './no-session-proxy'; -import { - getWDAUpgradeTimestamp, - resetTestProcesses, - getPIDsListeningOnPort, - BOOTSTRAP_PATH, -} from './utils'; +import {BOOTSTRAP_PATH, getWDAUpgradeTimestamp} from './utils'; import {XcodeBuild} from './xcodebuild'; import AsyncLock from 'async-lock'; -import {exec} from 'teen_process'; -import {bundleWDASim} from './check-dependencies'; import { WDA_RUNNER_BUNDLE_ID, - WDA_RUNNER_APP, WDA_BASE_URL, WDA_UPGRADE_TIMESTAMP_PATH, DEFAULT_TEST_BUNDLE_SUFFIX, } from './constants'; -import {Xctest} from 'appium-ios-device'; import {strongbox} from '@appium/strongbox'; -import type {WebDriverAgentArgs, AppleDevice} from './types'; +import type { + WebDriverAgentArgs, + AppleDevice, + XcodeBuildSettings, + RetrieveBuildSettingsOptions, + WdaHostOps, +} from './types'; +import { + createDefaultWdaHostOps, + createWdaStartupStrategy, + type WdaStartupStrategy, + type WdaStartupStrategyContext, +} from './wda-strategies'; const WDA_LAUNCH_TIMEOUT = 60 * 1000; const WDA_AGENT_PORT = 8100; -const WDA_CF_BUNDLE_NAME = 'WebDriverAgentRunner-Runner'; const SHARED_RESOURCES_GUARD = new AsyncLock(); const RECENT_MODULE_VERSION_ITEM_NAME = 'recentWdaModuleVersion'; +const URL_PROTOCOL_SEPARATOR = '://'; export class WebDriverAgent { - bootstrapPath: string; - agentPath: string; + bootstrapPath!: string; + agentPath!: string; readonly args: WebDriverAgentArgs; - private readonly log: AppiumLogger; readonly device: AppleDevice; readonly platformVersion?: string; readonly platformName?: string; readonly iosSdkVersion?: string; readonly host?: string; readonly isRealDevice: boolean; - private readonly wdaBundlePath?: string; - private readonly wdaLocalPort?: number; readonly wdaRemotePort: number; readonly wdaBaseUrl: string; readonly wdaBindingIP?: string; - private readonly prebuildWDA?: boolean; webDriverAgentUrl?: string; started: boolean; + updatedWDABundleId?: string; + noSessionProxy?: NoSessionProxy; + jwproxy?: JWProxy; + proxyReqRes?: any; + private readonly log: AppiumLogger; + private readonly wdaLocalPort?: number; + private readonly prebuildWDA?: boolean; private readonly wdaConnectionTimeout?: number; private readonly useXctestrunFile?: boolean; private readonly usePrebuiltWDA?: boolean; - private readonly derivedDataPath?: string; private readonly mjpegServerPort?: number; - updatedWDABundleId?: string; + private readonly maxHttpRequestBodySize?: number; private readonly wdaLaunchTimeout: number; private readonly usePreinstalledWDA?: boolean; - private xctestApiClient?: Xctest | null; private readonly updatedWDABundleIdSuffix: string; + private readonly hostOps: Required; + private activeStartupStrategy?: WdaStartupStrategy; private _xcodebuild?: XcodeBuild | null; - noSessionProxy?: NoSessionProxy; - jwproxy?: JWProxy; - proxyReqRes?: any; - private _url?: url.UrlWithStringQuery; + private _url?: URL; /** * Creates a new WebDriverAgent instance. @@ -76,7 +76,7 @@ export class WebDriverAgent { * @param log - Optional logger instance */ constructor(args: WebDriverAgentArgs, log: AppiumLogger | null = null) { - this.args = _.clone(args); + this.args = {...args}; this.log = log ?? defaultLogger; this.device = args.device; @@ -85,7 +85,6 @@ export class WebDriverAgent { this.iosSdkVersion = args.iosSdkVersion; this.host = args.host; this.isRealDevice = !!args.realDevice; - this.wdaBundlePath = args.wdaBundlePath; this.setWDAPaths(args.bootstrapPath, args.agentPath); @@ -107,15 +106,29 @@ export class WebDriverAgent { this.useXctestrunFile = args.useXctestrunFile; this.usePrebuiltWDA = args.usePrebuiltWDA; - this.derivedDataPath = args.derivedDataPath; this.mjpegServerPort = args.mjpegServerPort; + this.maxHttpRequestBodySize = args.maxHttpRequestBodySize; this.updatedWDABundleId = args.updatedWDABundleId; this.wdaLaunchTimeout = args.wdaLaunchTimeout || WDA_LAUNCH_TIMEOUT; this.usePreinstalledWDA = args.usePreinstalledWDA; - this.xctestApiClient = null; this.updatedWDABundleIdSuffix = args.updatedWDABundleIdSuffix ?? DEFAULT_TEST_BUNDLE_SUFFIX; + const defaultHostOps = createDefaultWdaHostOps(); + this.hostOps = { + simulator: { + ...defaultHostOps.simulator, + ...args.hostOps?.simulator, + }, + realDevicePreinstalled: { + ...defaultHostOps.realDevicePreinstalled, + ...args.hostOps?.realDevicePreinstalled, + }, + realDeviceXcodebuild: { + ...defaultHostOps.realDeviceXcodebuild, + ...args.hostOps?.realDeviceXcodebuild, + }, + }; this._xcodebuild = this.canSkipXcodebuild ? null @@ -143,6 +156,7 @@ export class WebDriverAgent { useXctestrunFile: this.useXctestrunFile, derivedDataPath: args.derivedDataPath, mjpegServerPort: this.mjpegServerPort, + maxHttpRequestBodySize: this.maxHttpRequestBodySize, allowProvisioningDeviceRegistration: args.allowProvisioningDeviceRegistration, resultBundlePath: args.resultBundlePath, resultBundleVersion: args.resultBundleVersion, @@ -186,48 +200,78 @@ export class WebDriverAgent { } /** - * Cleans up obsolete cached processes from previous WDA sessions - * that are listening on the same port but belong to different devices. + * Gets the base path for the WebDriverAgent URL. + * @returns The base path (empty string if root path) */ - async cleanupObsoleteProcesses(): Promise { - const obsoletePids = await getPIDsListeningOnPort( - this.url.port as string, - (cmdLine) => - cmdLine.includes('/WebDriverAgentRunner') && - !cmdLine.toLowerCase().includes(this.device.udid.toLowerCase()), - ); + get basePath(): string { + if (this.url.pathname === '/') { + return ''; + } + return this.url.pathname || ''; + } - if (_.isEmpty(obsoletePids)) { - this.log.debug( - `No obsolete cached processes from previous WDA sessions ` + - `listening on port ${this.url.port} have been found`, - ); - return; + /** + * Gets the WebDriverAgent URL. + * Constructs the URL from webDriverAgentUrl if provided, otherwise + * builds it from wdaBaseUrl, wdaBindingIP, and wdaLocalPort. + * @returns The parsed URL object + */ + get url(): URL { + if (!this._url) { + if (this.webDriverAgentUrl) { + this._url = this.toUrl(this.webDriverAgentUrl); + } else { + const port = this.wdaLocalPort || WDA_AGENT_PORT; + const parsedBaseUrl = this.toUrl(this.wdaBaseUrl || WDA_BASE_URL); + this._url = new URL( + `${parsedBaseUrl.protocol}//${this.wdaBindingIP || parsedBaseUrl.hostname}:${port}`, + ); + } } + return this._url; + } - this.log.info( - `Detected ${obsoletePids.length} obsolete cached process${obsoletePids.length === 1 ? '' : 'es'} ` + - `from previous WDA sessions. Cleaning them up`, - ); + /** + * Gets whether WebDriverAgent has fully started. + * @returns `true` if WDA has started, `false` otherwise + */ + get fullyStarted(): boolean { + return this.started; + } + + /** + * Sets whether WebDriverAgent has fully started. + * @param started - `true` if WDA has started, `false` otherwise + */ + set fullyStarted(started: boolean) { + this.started = started ?? false; + } + + /** + * Sets the WebDriverAgent URL. + * @param _url - The URL string to parse and set + */ + set url(_url: string) { + this._url = this.toUrl(_url); + } + + /** + * Cleans up obsolete cached processes from previous WDA sessions + * that are listening on the same port but belong to different devices. + */ + async cleanupObsoleteProcesses(): Promise { try { - await exec('kill', obsoletePids); + await this.hostOps.realDeviceXcodebuild.cleanupObsoleteProcesses?.({ + udid: this.device.udid, + port: this.url.port as string, + commandLineIncludes: '/WebDriverAgentRunner', + }); } catch (e: any) { - this.log.warn( - `Failed to kill obsolete cached process${obsoletePids.length === 1 ? '' : 'es'} '${obsoletePids}'. ` + - `Original error: ${e.message}`, - ); + this.log.warn(`Failed to clean obsolete cached processes. Original error: ${e.message}`); } } /** - * Gets the base path for the WebDriverAgent URL. - * @returns The base path (empty string if root path) - */ - get basePath(): string { - if (this.url.path === '/') { - return ''; - } - return this.url.path || ''; } /** @@ -252,53 +296,10 @@ export class WebDriverAgent { * @param sessionId Launch WDA and establish the session with this sessionId */ async launch(sessionId: string): Promise { - if (this.webDriverAgentUrl) { - this.log.info(`Using provided WebdriverAgent at '${this.webDriverAgentUrl}'`); - this.url = this.webDriverAgentUrl; - this.setupProxies(sessionId); - return await this.getStatus(); - } - - if (this.usePreinstalledWDA) { - return await this.launchWithPreinstalledWDA(sessionId); - } - - this.log.info('Launching WebDriverAgent on the device'); - - this.setupProxies(sessionId); - - if (!this.useXctestrunFile && !(await fs.exists(this.agentPath))) { - throw new Error( - `Trying to use WebDriverAgent project at '${this.agentPath}' but the ` + - 'file does not exist', - ); - } - - // useXctestrunFile and usePrebuiltWDA use existing dependencies - // It depends on user side - if (this.useXctestrunFile || this.usePrebuiltWDA) { - this.log.info('Skipped WDA project cleanup according to the provided capabilities'); - } else { - const synchronizationKey = path.normalize(this.bootstrapPath); - await SHARED_RESOURCES_GUARD.acquire( - synchronizationKey, - async () => await this._cleanupProjectIfFresh(), - ); - } - - // We need to provide WDA local port, because it might be occupied - await resetTestProcesses(this.device.udid, !this.isRealDevice); - - if (!this.noSessionProxy) { - throw new Error('noSessionProxy is not available'); - } - await this.xcodebuild.init(this.noSessionProxy); - - // Start the xcodebuild process - if (this.prebuildWDA) { - await this.xcodebuild.prebuild(); - } - return (await this.xcodebuild.start()) as StringRecord | null; + const startupStrategy = this.createStartupStrategy(); + this.log.info(`Selected '${startupStrategy.name}' WebDriverAgent startup strategy`); + this.activeStartupStrategy = startupStrategy; + return await startupStrategy.launch(sessionId); } /** @@ -307,55 +308,10 @@ export class WebDriverAgent { * @returns `true` if source is fresh (all required files exist), `false` otherwise */ async isSourceFresh(): Promise { - const existsPromises = ['Resources', `Resources${path.sep}WebDriverAgent.bundle`].map( + const existsPromises = ['Resources', path.join('Resources', 'WebDriverAgent.bundle')].map( (subPath) => fs.exists(path.resolve(this.bootstrapPath, subPath)), ); - return (await B.all(existsPromises)).some((v) => v === false); - } - - private async parseBundleId(wdaBundlePath: string): Promise { - const infoPlistPath = path.join(wdaBundlePath, 'Info.plist'); - const infoPlist = (await plist.parsePlist(await fs.readFile(infoPlistPath))) as { - CFBundleIdentifier?: string; - }; - if (!infoPlist.CFBundleIdentifier) { - throw new Error(`Could not find bundle id in '${infoPlistPath}'`); - } - return infoPlist.CFBundleIdentifier; - } - - private async fetchWDABundle(): Promise { - if (!this.derivedDataPath) { - return await bundleWDASim(this.xcodebuild); - } - const wdaBundlePaths = await fs.glob(`${this.derivedDataPath}/**/*${WDA_RUNNER_APP}/`, { - absolute: true, - }); - if (_.isEmpty(wdaBundlePaths)) { - throw new Error(`Could not find the WDA bundle in '${this.derivedDataPath}'`); - } - return wdaBundlePaths[0]; - } - - private setupProxies(sessionId: string): void { - const proxyOpts: any = { - log: this.log, - server: this.url.hostname ?? undefined, - port: parseInt(this.url.port ?? '', 10) || undefined, - base: this.basePath, - timeout: this.wdaConnectionTimeout, - keepAlive: true, - scheme: this.url.protocol ? this.url.protocol.replace(':', '') : 'http', - }; - if (this.args.reqBasePath) { - proxyOpts.reqBasePath = this.args.reqBasePath; - } - - this.jwproxy = new JWProxy(proxyOpts); - this.jwproxy.sessionId = sessionId; - this.proxyReqRes = this.jwproxy.proxyReqRes.bind(this.jwproxy); - - this.noSessionProxy = new NoSessionProxy(proxyOpts); + return (await Promise.all(existsPromises)).every((v) => v === true); } /** @@ -363,29 +319,7 @@ export class WebDriverAgent { * Handles both preinstalled WDA and xcodebuild-based sessions. */ async quit(): Promise { - if (this.usePreinstalledWDA) { - this.log.info('Stopping the XCTest session'); - if (this.xctestApiClient) { - this.xctestApiClient.stop(); - this.xctestApiClient = null; - } else { - try { - await this.device.simctl.terminateApp(this.bundleIdForXctest); - } catch (e: any) { - this.log.warn(e.message); - } - } - } else if (!this.args.webDriverAgentUrl) { - this.log.info('Shutting down sub-processes'); - if (this._xcodebuild) { - await this.xcodebuild.quit(); - } - } else { - this.log.debug( - 'Do not stop xcodebuild nor XCTest session ' + - 'since the WDA session is managed by outside this driver.', - ); - } + await (this.activeStartupStrategy ?? this.createStartupStrategy()).quit(); if (this.jwproxy) { this.jwproxy.sessionId = null; @@ -398,53 +332,25 @@ export class WebDriverAgent { // then clean that up. If the url was supplied, we want to keep it this.webDriverAgentUrl = undefined; } + this.activeStartupStrategy = undefined; } /** - * Gets the WebDriverAgent URL. - * Constructs the URL from webDriverAgentUrl if provided, otherwise - * builds it from wdaBaseUrl, wdaBindingIP, and wdaLocalPort. - * @returns The parsed URL object + * Retrieves Xcode build settings. + * @param options - Optional scheme, SDK, configuration, or destination + * @returns Build settings, or `undefined` if xcodebuild is skipped or settings cannot be determined */ - get url(): url.UrlWithStringQuery { - if (!this._url) { - if (this.webDriverAgentUrl) { - this._url = url.parse(this.webDriverAgentUrl); - } else { - const port = this.wdaLocalPort || WDA_AGENT_PORT; - const {protocol, hostname} = url.parse(this.wdaBaseUrl || WDA_BASE_URL); - this._url = url.parse(`${protocol}//${this.wdaBindingIP || hostname}:${port}`); - } + async retrieveBuildSettings( + options?: RetrieveBuildSettingsOptions, + ): Promise { + if (this.canSkipXcodebuild) { + return; } - return this._url; - } - - /** - * Sets the WebDriverAgent URL. - * @param _url - The URL string to parse and set - */ - set url(_url: string) { - this._url = url.parse(_url); - } - - /** - * Gets whether WebDriverAgent has fully started. - * @returns `true` if WDA has started, `false` otherwise - */ - get fullyStarted(): boolean { - return this.started; + return await this.xcodebuild.retrieveBuildSettings(options); } /** - * Sets whether WebDriverAgent has fully started. - * @param started - `true` if WDA has started, `false` otherwise - */ - set fullyStarted(started: boolean) { - this.started = started ?? false; - } - - /** - * Retrieves the Xcode derived data path for WebDriverAgent. + * @deprecated Use {@link retrieveBuildSettings} instead. Will be removed in a future release. * @returns The derived data path, or `undefined` if xcodebuild is skipped */ async retrieveDerivedDataPath(): Promise { @@ -457,13 +363,14 @@ export class WebDriverAgent { /** * Reuse running WDA if it has the same bundle id with updatedWDABundleId. * Or reuse it if it has the default id without updatedWDABundleId. - * Uninstall it if the method faces an exception for the above situation. + * + * @returns The WDA URL used for caching on success, or `undefined` if caching was skipped. */ - async setupCaching(): Promise { + async setupCaching(): Promise { const status = await this.getStatus(0); if (!status || !status.build) { this.log.debug('WDA is currently not running. There is nothing to cache'); - return; + return undefined; } const {productBundleIdentifier, upgradedAt} = status.build as any; @@ -474,9 +381,9 @@ export class WebDriverAgent { this.updatedWDABundleId !== productBundleIdentifier ) { this.log.info( - `Will uninstall running WDA since it has different bundle id. The actual value is '${productBundleIdentifier}'.`, + `Will not reuse running WDA since it has different bundle id. The actual value is '${productBundleIdentifier}'.`, ); - return await this.uninstall(); + return undefined; } // for simulator if ( @@ -485,9 +392,9 @@ export class WebDriverAgent { WDA_RUNNER_BUNDLE_ID !== productBundleIdentifier ) { this.log.info( - `Will uninstall running WDA since its bundle id is not equal to the default value ${WDA_RUNNER_BUNDLE_ID}`, + `Will not reuse running WDA since its bundle id is not equal to the default value ${WDA_RUNNER_BUNDLE_ID}`, ); - return await this.uninstall(); + return undefined; } const actualUpgradeTimestamp = await getWDAUpgradeTimestamp(); @@ -496,30 +403,110 @@ export class WebDriverAgent { if ( actualUpgradeTimestamp && upgradedAt && - _.toLower(`${actualUpgradeTimestamp}`) !== _.toLower(`${upgradedAt}`) + `${actualUpgradeTimestamp}`.toLowerCase() !== `${upgradedAt}`.toLowerCase() ) { this.log.info( - 'Will uninstall running WDA since it has different version in comparison to the one ' + + 'Will not reuse running WDA since it has different version in comparison to the one ' + `which is bundled with appium-xcuitest-driver module (${actualUpgradeTimestamp} != ${upgradedAt})`, ); - return await this.uninstall(); + return undefined; } + const cachedUrl = this.url.href; const message = util.hasValue(productBundleIdentifier) - ? `Will reuse previously cached WDA instance at '${this.url.href}' with '${productBundleIdentifier}'` - : `Will reuse previously cached WDA instance at '${this.url.href}'`; + ? `Will reuse previously cached WDA instance at '${cachedUrl}' with '${productBundleIdentifier}'` + : `Will reuse previously cached WDA instance at '${cachedUrl}'`; this.log.info( `${message}. Set the wdaLocalPort capability to a value different from ${this.url.port} if this is an undesired behavior.`, ); - this.webDriverAgentUrl = this.url.href; + this.webDriverAgentUrl = cachedUrl; + return cachedUrl; } - /** - * Quit and uninstall running WDA. - */ - async quitAndUninstall(): Promise { - await this.quit(); - await this.uninstall(); + private createStartupStrategy(): WdaStartupStrategy { + const context: WdaStartupStrategyContext = { + argsWebDriverAgentUrl: this.args.webDriverAgentUrl, + webDriverAgentUrl: this.webDriverAgentUrl, + usePreinstalledWDA: this.usePreinstalledWDA, + useXctestrunFile: this.useXctestrunFile, + usePrebuiltWDA: this.usePrebuiltWDA, + prebuildWDA: this.prebuildWDA, + isRealDevice: this.isRealDevice, + device: this.device, + agentPath: this.agentPath, + bootstrapPath: this.bootstrapPath, + bundleIdForXctest: this.bundleIdForXctest, + wdaLocalPort: this.wdaLocalPort, + wdaRemotePort: this.wdaRemotePort, + wdaBindingIP: this.wdaBindingIP, + wdaLaunchTimeout: this.wdaLaunchTimeout, + mjpegServerPort: this.mjpegServerPort, + maxHttpRequestBodySize: this.maxHttpRequestBodySize, + platformName: this.platformName, + platformVersion: this.platformVersion, + log: this.log, + hostOps: this.hostOps, + setWebDriverAgentUrl: (value) => { + this.webDriverAgentUrl = value; + }, + setUrl: (value) => { + this.url = value; + }, + setupProxies: (sessionId) => this.setupProxies(sessionId), + getStatus: async (timeoutMs) => await this.getStatus(timeoutMs), + cleanupProjectIfFresh: async () => { + const synchronizationKey = path.normalize(this.bootstrapPath); + await SHARED_RESOURCES_GUARD.acquire( + synchronizationKey, + async () => await this._cleanupProjectIfFresh(), + ); + }, + xcodebuild: () => this.xcodebuild, + noSessionProxy: () => { + if (!this.noSessionProxy) { + throw new Error('noSessionProxy is not available'); + } + return this.noSessionProxy; + }, + setStarted: (started) => { + this.started = started; + }, + }; + return createWdaStartupStrategy(context); + } + + private setupProxies(sessionId: string): void { + const proxyOpts: any = { + log: this.log, + server: this.url.hostname ?? undefined, + port: parseInt(this.url.port ?? '', 10) || undefined, + base: this.basePath, + timeout: this.wdaConnectionTimeout, + keepAlive: true, + scheme: this.url.protocol ? this.url.protocol.replace(':', '') : 'http', + headers: this.args.extraRequestHeaders, + }; + if (this.args.reqBasePath) { + proxyOpts.reqBasePath = this.args.reqBasePath; + } + + this.jwproxy = new JWProxy(proxyOpts); + this.jwproxy.sessionId = sessionId; + this.proxyReqRes = this.jwproxy.proxyReqRes.bind(this.jwproxy); + + this.noSessionProxy = new NoSessionProxy(proxyOpts); + } + + private toUrl(value: string): URL { + // Treat values without `://` as host/path inputs and normalize to http. + if (!value.includes(URL_PROTOCOL_SEPARATOR)) { + return new URL(`http://${value}`); + } + try { + return new URL(value); + } catch { + throw new Error(`Invalid URL: ${value}`); + } } private setWDAPaths(bootstrapPath?: string, agentPath?: string): void { @@ -533,10 +520,6 @@ export class WebDriverAgent { this.log.info(`Using WDA agent: '${this.agentPath}'`); } - private async isRunning(): Promise { - return !!(await this.getStatus()); - } - /** * Return current running WDA's status like below * { @@ -560,16 +543,18 @@ export class WebDriverAgent { */ private async getStatus(timeoutMs: number = 0): Promise { const noSessionProxy = new NoSessionProxy({ + scheme: this.url.protocol ? this.url.protocol.replace(':', '') : 'http', server: this.url.hostname ?? undefined, port: parseInt(this.url.port ?? '', 10) || undefined, base: this.basePath, timeout: 3000, + headers: this.args.extraRequestHeaders, }); const sendGetStatus = async () => (await noSessionProxy.command('/status', 'GET')) as StringRecord; - if (_.isNil(timeoutMs) || timeoutMs <= 0) { + if (timeoutMs == null || timeoutMs <= 0) { try { return await sendGetStatus(); } catch (err: any) { @@ -603,37 +588,11 @@ export class WebDriverAgent { `Failed to get the status endpoint in ${timeoutMs} ms. ` + `The last error while accessing ${this.url.href}: ${lastError}. Original error:: ${err.message}.`, ); - throw new Error(`WDA was not ready in ${timeoutMs} ms.`); + throw new Error(`WDA was not ready in ${timeoutMs} ms.`, {cause: err}); } return status; } - /** - * Uninstall WDAs from the test device. - * Over Xcode 11, multiple WDA can be in the device since Xcode 11 generates different WDA. - * Appium does not expect multiple WDAs are running on a device. - */ - private async uninstall(): Promise { - try { - const bundleIds = await this.device.getUserInstalledBundleIdsByBundleName(WDA_CF_BUNDLE_NAME); - if (_.isEmpty(bundleIds)) { - this.log.debug('No WDAs on the device.'); - return; - } - - this.log.debug(`Uninstalling WDAs: '${bundleIds}'`); - for (const bundleId of bundleIds) { - await this.device.removeApp(bundleId); - } - } catch (e: any) { - this.log.debug(e); - this.log.warn( - `WebDriverAgent uninstall failed. Perhaps, it is already uninstalled? ` + - `Original error: ${e.message}`, - ); - } - } - private async _cleanupProjectIfFresh(): Promise { if (this.canSkipXcodebuild) { return; @@ -700,73 +659,4 @@ export class WebDriverAgent { this.log.warn(`Cannot perform WebDriverAgent project cleanup. Original error: ${e.message}`); } } - - /** - * Launch WDA with preinstalled package with 'xcrun devicectl device process launch'. - * The WDA package must be prepared properly like published via - * https://github.com/appium/WebDriverAgent/releases - * with proper sign for this case. - * - * When we implement launching XCTest service via appium-ios-device, - * this implementation can be replaced with it. - * - * @param opts launching WDA with devicectl command options. - */ - private async _launchViaDevicectl( - opts: {env?: Record} = {}, - ): Promise { - const {env} = opts; - - await this.device.devicectl.launchApp(this.bundleIdForXctest, {env, terminateExisting: true}); - } - - /** - * Launch WDA with preinstalled package without xcodebuild. - * @param sessionId Launch WDA and establish the session with this sessionId - */ - private async launchWithPreinstalledWDA(sessionId: string): Promise { - const xctestEnv: Record = { - USE_PORT: this.wdaLocalPort || WDA_AGENT_PORT, - WDA_PRODUCT_BUNDLE_IDENTIFIER: this.bundleIdForXctest, - }; - if (this.mjpegServerPort) { - xctestEnv.MJPEG_SERVER_PORT = this.mjpegServerPort; - } - if (this.wdaBindingIP) { - xctestEnv.USE_IP = this.wdaBindingIP; - } - this.log.info('Launching WebDriverAgent on the device without xcodebuild'); - if (this.isRealDevice) { - // Current method to launch WDA process can be done via 'xcrun devicectl', - // but it has limitation about the WDA preinstalled package. - // https://github.com/appium/appium/issues/19206#issuecomment-2014182674 - if (this.platformVersion && util.compareVersions(this.platformVersion, '>=', '17.0')) { - await this._launchViaDevicectl({env: xctestEnv}); - } else { - this.xctestApiClient = new Xctest(this.device.udid, this.bundleIdForXctest, null, { - env: xctestEnv, - }); - await this.xctestApiClient.start(); - } - } else { - await this.device.simctl.exec('launch', { - args: ['--terminate-running-process', this.device.udid, this.bundleIdForXctest], - env: xctestEnv, - }); - } - - this.setupProxies(sessionId); - let status: StringRecord | null; - try { - status = await this.getStatus(this.wdaLaunchTimeout); - } catch { - throw new Error( - `Failed to start the preinstalled WebDriverAgent in ${this.wdaLaunchTimeout} ms. ` + - `The WebDriverAgent might not be properly built or the device might be locked. ` + - `The 'appium:wdaLaunchTimeout' capability modifies the timeout.`, - ); - } - this.started = true; - return status; - } } diff --git a/lib/xcodebuild.ts b/lib/xcodebuild.ts index f21289906..962607915 100644 --- a/lib/xcodebuild.ts +++ b/lib/xcodebuild.ts @@ -1,20 +1,18 @@ import {retryInterval} from 'asyncbox'; import {SubProcess, exec} from 'teen_process'; -import {logger, timing} from '@appium/support'; +import {logger, timing, util} from '@appium/support'; import type {AppiumLogger, StringRecord} from '@appium/types'; import {log as defaultLogger} from './logger'; -import B from 'bluebird'; -import { - setRealDeviceSecurity, - setXctestrunFile, - killProcess, - getWDAUpgradeTimestamp, - isTvOS, -} from './utils'; -import _ from 'lodash'; +import {getWDAUpgradeTimestamp, isTvOS, setRealDeviceSecurity, setXctestrunFile} from './utils'; import path from 'node:path'; import {WDA_RUNNER_BUNDLE_ID} from './constants'; -import type {AppleDevice, XcodeBuildArgs} from './types'; +import type { + AppleDevice, + RetrieveBuildSettingsOptions, + XcodeBuildArgs, + XcodeBuildSettings, + XcodeShowBuildSettingsEntry, +} from './types'; import type {NoSessionProxy} from './no-session-proxy'; const DEFAULT_SIGNING_ID = 'iPhone Developer'; @@ -30,7 +28,7 @@ const IGNORED_ERRORS = [ 'Failed to remove screenshot at path', ]; const IGNORED_ERRORS_PATTERN = new RegExp( - '(' + IGNORED_ERRORS.map((errStr) => _.escapeRegExp(errStr)).join('|') + ')', + '(' + IGNORED_ERRORS.map((errStr) => util.escapeRegExp(errStr)).join('|') + ')', ); const RUNNER_SCHEME_TV = 'WebDriverAgentRunner_tvOS'; @@ -42,40 +40,43 @@ const REAL_DEVICES_CONFIG_DOCS_LINK = const xcodeLog = logger.getLogger('Xcode'); export class XcodeBuild { - xcodebuild?: SubProcess; readonly device: AppleDevice; - private readonly log: AppiumLogger; readonly realDevice: boolean; readonly agentPath: string; readonly bootstrapPath: string; readonly platformVersion?: string; readonly platformName?: string; readonly iosSdkVersion?: string; + readonly xcodeSigningId: string; + private xcodebuild?: SubProcess; + private usePrebuiltWDA?: boolean; + private derivedDataPath?: string; + private readonly log: AppiumLogger; private readonly showXcodeLog?: boolean; private readonly xcodeConfigFile?: string; private readonly xcodeOrgId?: string; - readonly xcodeSigningId: string; private readonly keychainPath?: string; private readonly keychainPassword?: string; - usePrebuiltWDA?: boolean; private readonly useSimpleBuildTest?: boolean; private readonly useXctestrunFile?: boolean; private readonly launchTimeout?: number; private readonly wdaRemotePort?: number; private readonly wdaBindingIP?: string; private readonly updatedWDABundleId?: string; - derivedDataPath?: string; private readonly mjpegServerPort?: number; + private readonly maxHttpRequestBodySize?: number; private readonly prebuildDelay: number; private readonly allowProvisioningDeviceRegistration?: boolean; private readonly resultBundlePath?: string; private readonly resultBundleVersion?: string; private _didBuildFail: boolean; private _didProcessExit: boolean; - private _derivedDataPathPromise?: Promise; + private readonly _buildSettingsPromises = new Map< + string, + Promise + >(); private noSessionProxy?: NoSessionProxy; private xctestrunFilePath?: string; - agentUrl?: string; /** * Creates a new XcodeBuild instance. @@ -118,8 +119,10 @@ export class XcodeBuild { this.derivedDataPath = args.derivedDataPath; this.mjpegServerPort = args.mjpegServerPort; + this.maxHttpRequestBodySize = args.maxHttpRequestBodySize; - this.prebuildDelay = _.isNumber(args.prebuildDelay) ? args.prebuildDelay : PREBUILD_DELAY; + this.prebuildDelay = + typeof args.prebuildDelay === 'number' ? args.prebuildDelay : PREBUILD_DELAY; this.allowProvisioningDeviceRegistration = args.allowProvisioningDeviceRegistration; @@ -151,14 +154,30 @@ export class XcodeBuild { bootstrapPath: this.bootstrapPath, wdaRemotePort: this.wdaRemotePort || 8100, wdaBindingIP: this.wdaBindingIP, + maxHttpRequestBodySize: this.maxHttpRequestBodySize, }); return; } } /** - * Retrieves the Xcode derived data path for the build. - * Uses cached value if available, otherwise queries xcodebuild for BUILD_DIR. + * Retrieves Xcode build settings via `xcodebuild -showBuildSettings -json`. + * @param options - Optional scheme, SDK, configuration, or destination + * @returns Build settings for the `build` action, or `undefined` if they cannot be determined + */ + async retrieveBuildSettings( + options?: RetrieveBuildSettingsOptions, + ): Promise { + const cacheKey = buildSettingsCacheKey(options); + let promise = this._buildSettingsPromises.get(cacheKey); + if (!promise) { + promise = this.fetchBuildSettings(options); + this._buildSettingsPromises.set(cacheKey, promise); + } + return await promise; + } + + /** * @returns The derived data path, or `undefined` if it cannot be determined */ async retrieveDerivedDataPath(): Promise { @@ -166,33 +185,21 @@ export class XcodeBuild { return this.derivedDataPath; } - // avoid race conditions - if (this._derivedDataPathPromise) { - return await this._derivedDataPathPromise; + // iOS/tvOS share the same derived data path + const buildSettings = await this.retrieveBuildSettings({ + scheme: 'WebDriverAgentRunner', + }); + const buildDir = buildSettings?.BUILD_DIR; + if (!buildDir) { + this.log.warn('Cannot parse WDA BUILD_DIR from build settings'); + return; } - this._derivedDataPathPromise = (async () => { - let stdout: string; - try { - ({stdout} = await exec('xcodebuild', ['-project', this.agentPath, '-showBuildSettings'])); - } catch (err: any) { - this.log.warn(`Cannot retrieve WDA build settings. Original error: ${err.message}`); - return; - } - - const pattern = /^\s*BUILD_DIR\s+=\s+(\/.*)/m; - const match = pattern.exec(stdout); - if (!match) { - this.log.warn(`Cannot parse WDA build dir from ${_.truncate(stdout, {length: 300})}`); - return; - } - this.log.debug(`Parsed BUILD_DIR configuration value: '${match[1]}'`); - // Derived data root is two levels higher over the build dir - this.derivedDataPath = path.dirname(path.dirname(path.normalize(match[1]))); - this.log.debug(`Got derived data root: '${this.derivedDataPath}'`); - return this.derivedDataPath; - })(); - return await this._derivedDataPathPromise; + this.log.debug(`Parsed BUILD_DIR configuration value: '${buildDir}'`); + // Derived data root is two levels higher over the build dir + this.derivedDataPath = path.dirname(path.dirname(path.normalize(buildDir))); + this.log.debug(`Got derived data root: '${this.derivedDataPath}'`); + return this.derivedDataPath; } /** @@ -207,7 +214,7 @@ export class XcodeBuild { if (this.prebuildDelay > 0) { // pause a moment - await B.delay(this.prebuildDelay); + await new Promise((resolve) => setTimeout(resolve, this.prebuildDelay)); } } @@ -242,7 +249,7 @@ export class XcodeBuild { throw new Error('xcodebuild subprocess was not created'); } const xcodebuild = this.xcodebuild; - return await new B((resolve, reject) => { + return await new Promise((resolve, reject) => { xcodebuild.once('exit', (code, signal) => { xcodeLog.error(`xcodebuild exited with code '${code}' and signal '${signal}'`); xcodebuild.removeAllListeners(); @@ -293,7 +300,73 @@ export class XcodeBuild { * Stops the xcodebuild process and cleans up resources. */ async quit(): Promise { - await killProcess('xcodebuild', this.xcodebuild); + const xcodebuild = this.xcodebuild; + if (!xcodebuild || !xcodebuild.isRunning) { + return; + } + + this.log.info(`Shutting down 'xcodebuild' process (pid '${xcodebuild.proc?.pid}')`); + + try { + await xcodebuild.stop('SIGTERM', 1000); + return; + } catch (err: unknown) { + if (!(err as Error)?.message?.includes(`Process didn't end after`)) { + throw err; + } + this.log.debug( + `xcodebuild process did not end in a timely fashion: '${(err as Error)?.message}'.`, + ); + } + + try { + await xcodebuild.stop('SIGKILL'); + } catch (err: unknown) { + if ((err as Error)?.message?.includes('not currently running')) { + // The process ended but for some reason we were not informed. + return; + } + throw err; + } + } + + private async fetchBuildSettings( + options?: RetrieveBuildSettingsOptions, + ): Promise { + const schemeLabel = options?.scheme ?? 'default'; + let stdout: string; + try { + ({stdout} = await exec('xcodebuild', [ + '-project', + this.agentPath, + '-showBuildSettings', + '-json', + ...buildSettingsArgsFromOptions(options), + ])); + } catch (err: any) { + this.log.warn( + `Cannot retrieve WDA build settings for scheme '${schemeLabel}'. Original error: ${err.message}`, + ); + return; + } + + let entries: XcodeShowBuildSettingsEntry[]; + try { + entries = JSON.parse(stdout) as XcodeShowBuildSettingsEntry[]; + } catch (err: any) { + this.log.warn( + `Cannot parse WDA build settings for scheme '${schemeLabel}' from ${util.truncateString(stdout, 300)}. ` + + `Original error: ${err.message}`, + ); + return; + } + + const entry = entries.find(({action}) => action === 'build') ?? entries[0]; + if (!entry?.buildSettings) { + this.log.warn(`Cannot find build settings for scheme '${schemeLabel}'`); + return; + } + return entry.buildSettings; } private getCommand(buildOnly: boolean = false): {cmd: string; args: string[]} { @@ -336,11 +409,10 @@ export class XcodeBuild { } args.push('-destination', `id=${this.device.udid}`); - let versionMatch: RegExpMatchArray | null = null; - if ( - this.platformVersion && - (versionMatch = new RegExp(/^(\d+)\.(\d+)/).exec(this.platformVersion)) - ) { + const versionMatch = this.platformVersion + ? new RegExp(/^(\d+)\.(\d+)/).exec(this.platformVersion) + : null; + if (versionMatch) { args.push( `${isTvOS(this.platformName || '') ? 'TV' : 'IPHONE'}OS_DEPLOYMENT_TARGET=${versionMatch[1]}.${versionMatch[2]}`, ); @@ -380,10 +452,8 @@ export class XcodeBuild { } private async createSubProcess(buildOnly: boolean = false): Promise { - if (!this.useXctestrunFile && this.realDevice) { - if (this.keychainPath && this.keychainPassword) { - await setRealDeviceSecurity(this.keychainPath, this.keychainPassword); - } + if (!this.useXctestrunFile && this.realDevice && this.keychainPath && this.keychainPassword) { + await setRealDeviceSecurity(this.keychainPath, this.keychainPassword); } const {cmd, args} = this.getCommand(buildOnly); @@ -395,6 +465,10 @@ export class XcodeBuild { USE_PORT: this.wdaRemotePort, WDA_PRODUCT_BUNDLE_IDENTIFIER: this.updatedWDABundleId || WDA_RUNNER_BUNDLE_ID, }); + delete env.MAX_HTTP_REQUEST_BODY_SIZE; + if (this.maxHttpRequestBodySize) { + env.MAX_HTTP_REQUEST_BODY_SIZE = this.maxHttpRequestBodySize; + } if (this.mjpegServerPort) { // https://github.com/appium/WebDriverAgent/pull/105 env.MJPEG_SERVER_PORT = this.mjpegServerPort; @@ -415,9 +489,10 @@ export class XcodeBuild { }); let logXcodeOutput = !!this.showXcodeLog; - const logMsg = _.isBoolean(this.showXcodeLog) - ? `Output from xcodebuild ${this.showXcodeLog ? 'will' : 'will not'} be logged` - : 'Output from xcodebuild will only be logged if any errors are present there'; + const logMsg = + typeof this.showXcodeLog === 'boolean' + ? `Output from xcodebuild ${this.showXcodeLog ? 'will' : 'will not'} be logged` + : 'Output from xcodebuild will only be logged if any errors are present there'; this.log.debug(`${logMsg}. To change this, use 'showXcodeLog' desired capability`); const onStreamLine = (line: string) => { @@ -461,18 +536,17 @@ export class XcodeBuild { } const proxyTimeout = noSessionProxy.timeout; - noSessionProxy.timeout = 1000; + (noSessionProxy as any).timeout = 1000; try { currentStatus = (await noSessionProxy.command('/status', 'GET')) as StringRecord; - if (currentStatus && currentStatus.ios && (currentStatus.ios as any).ip) { - this.agentUrl = (currentStatus.ios as any).ip; - } this.log.debug(`WebDriverAgent information:`); this.log.debug(JSON.stringify(currentStatus, null, 2)); } catch (err: any) { - throw new Error(`Unable to connect to running WebDriverAgent: ${err.message}`); + throw new Error(`Unable to connect to running WebDriverAgent: ${err.message}`, { + cause: err, + }); } finally { - noSessionProxy.timeout = proxyTimeout; + (noSessionProxy as any).timeout = proxyTimeout; } }); @@ -489,8 +563,33 @@ export class XcodeBuild { throw new Error( `We were not able to retrieve the /status response from the WebDriverAgent server after ${timeout}ms timeout.` + `Try to increase the value of 'appium:wdaLaunchTimeout' capability as a possible workaround.`, + {cause: err}, ); } return currentStatus; } } + +function buildSettingsArgsFromOptions(options?: RetrieveBuildSettingsOptions): string[] { + const args: string[] = []; + if (!options) { + return args; + } + if (options.scheme) { + args.push('-scheme', options.scheme); + } + if (options.sdk) { + args.push('-sdk', options.sdk); + } + if (options.configuration) { + args.push('-configuration', options.configuration); + } + if (options.destination) { + args.push('-destination', options.destination); + } + return args; +} + +function buildSettingsCacheKey(options?: RetrieveBuildSettingsOptions): string { + return buildSettingsArgsFromOptions(options).join('\0'); +} diff --git a/package.json b/package.json index 2e43649a3..bb44e64e7 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "appium-webdriveragent", - "version": "11.4.1", + "version": "15.0.0", "description": "Package bundling WebDriverAgent", - "main": "./build/index.js", - "types": "./build/index.d.ts", + "main": "./build/lib/index.js", + "types": "./build/lib/index.d.ts", "scripts": { "build": "tsc -b", "dev": "npm run build -- --watch", @@ -54,41 +54,40 @@ "@appium/types": "^1.0.0-rc.1", "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", - "@types/bluebird": "^3.5.38", - "@types/lodash": "^4.14.196", + "@types/async-lock": "^1.4.2", + "@types/chai": "^5.2.3", + "@types/chai-as-promised": "^8.0.2", "@types/mocha": "^10.0.1", - "@types/node": "^25.0.0", + "@types/node": "^26.0.0", + "@types/sinon": "^21.0.1", "appium-xcode": "^6.0.0", "chai": "^6.0.0", "chai-as-promised": "^8.0.0", "conventional-changelog-conventionalcommits": "^9.0.0", - "node-simctl": "^8.0.0", "mocha": "^11.0.1", + "node-simctl": "^8.0.0", "prettier": "^3.0.0", "semantic-release": "^25.0.2", "semver": "^7.3.7", - "sinon": "^21.0.0", + "sinon": "^22.0.0", "ts-node": "^10.9.1", - "typescript": "^5.4.2" + "typescript": "^6.0.2" }, "dependencies": { - "@appium/base-driver": "^10.0.0-rc.1", + "@appium/base-driver": "^10.3.0", "@appium/strongbox": "^1.0.0-rc.1", - "@appium/support": "^7.0.0-rc.1", - "appium-ios-device": "^3.0.0", + "@appium/support": "^7.2.1", "appium-ios-simulator": "^8.0.0", "async-lock": "^1.0.0", "asyncbox": "^6.1.0", - "axios": "^1.4.0", - "bluebird": "^3.5.5", - "lodash": "^4.17.11", + "axios": "^1.16.0", "teen_process": "^4.0.7" }, "files": [ - "index.ts", "lib", - "build", + "build/lib", "Scripts/build.sh", + "Scripts/embed-runner-icon.sh", "Scripts/*.mjs", "Configurations", "PrivateHeaders", @@ -97,7 +96,6 @@ "WebDriverAgentRunner", "WebDriverAgentTests", "XCTWebDriverAgentLib", - "CHANGELOG.md", - "!build/test" + "CHANGELOG.md" ] } diff --git a/test/functional/helpers/simulator.ts b/test/functional/helpers/simulator.ts index 36a4df3e1..a72197741 100644 --- a/test/functional/helpers/simulator.ts +++ b/test/functional/helpers/simulator.ts @@ -1,13 +1,14 @@ -import _ from 'lodash'; import {Simctl} from 'node-simctl'; import {retryInterval} from 'asyncbox'; import {killAllSimulators as simKill} from 'appium-ios-simulator'; import {resetTestProcesses} from '../../../lib/utils'; import type {AppleDevice} from '../../../lib/types'; +type SimulatorTestDevice = AppleDevice & {simctl: Simctl}; + export async function killAllSimulators(): Promise { const simctl = new Simctl(); - const allDevices = _.flatMap(_.values(await simctl.getDevices())); + const allDevices = Object.values(await simctl.getDevices()).flat(); const bootedDevices = allDevices.filter((device) => device.state === 'Booted'); for (const {udid} of bootedDevices) { @@ -20,10 +21,10 @@ export async function killAllSimulators(): Promise { await simKill(); } -export async function shutdownSimulator(device: AppleDevice): Promise { +export async function shutdownSimulator(device: SimulatorTestDevice): Promise { // stop XCTest processes if running to avoid unexpected side effects await resetTestProcesses(device.udid, true); - await device.shutdown(); + await device.simctl.shutdownDevice(); } export async function deleteDeviceWithRetry(udid: string): Promise { diff --git a/test/functional/webdriveragent-e2e-specs.ts b/test/functional/webdriveragent-e2e-specs.ts index 5d98329c1..0e93cd912 100644 --- a/test/functional/webdriveragent-e2e-specs.ts +++ b/test/functional/webdriveragent-e2e-specs.ts @@ -12,6 +12,8 @@ import type {AppleDevice} from '../../lib/types'; chai.use(chaiAsPromised); +type SimulatorTestDevice = AppleDevice & {simctl: Simctl}; + const MOCHA_TIMEOUT_MS = 60 * 1000 * 5; const SIM_DEVICE_NAME = 'webDriverAgentTest'; @@ -35,13 +37,13 @@ describe('WebDriverAgent', function () { this.timeout(MOCHA_TIMEOUT_MS); describe('with fresh sim', function () { - let device: AppleDevice; + let device: SimulatorTestDevice; let simctl: Simctl; before(async function () { simctl = new Simctl(); simctl.udid = await simctl.createDevice(SIM_DEVICE_NAME, DEVICE_NAME, PLATFORM_VERSION); - device = await getSimulator(simctl.udid); + device = (await getSimulator(simctl.udid)) as SimulatorTestDevice; // Prebuild WDA const wda = new WebDriverAgent({ @@ -67,7 +69,10 @@ describe('WebDriverAgent', function () { this.timeout(6 * 60 * 1000); beforeEach(async function () { await killAllSimulators(); - await device.run({startupTimeout: SIM_STARTUP_TIMEOUT_MS}); + await device.simctl.startBootMonitor({ + shouldPreboot: true, + timeout: SIM_STARTUP_TIMEOUT_MS, + }); }); afterEach(async function () { try { diff --git a/test/unit/utils-specs.ts b/test/unit/utils-specs.ts index d7e3ef01c..9d4a90804 100644 --- a/test/unit/utils-specs.ts +++ b/test/unit/utils-specs.ts @@ -42,7 +42,7 @@ describe('utils', function () { await expect(getXctestrunFilePath(deviceInfo, sdkVersion, bootstrapPath)).to.eventually.equal( path.resolve(`${bootstrapPath}/${udid}_${sdkVersion}.xctestrun`), ); - sandbox.assert.notCalled(fs.copyFile); + sandbox.assert.notCalled(fs.copyFile as any); }); it('should return sdk based path without udid, copy them', async function () { @@ -102,7 +102,7 @@ describe('utils', function () { await expect(getXctestrunFilePath(deviceInfo, sdkVersion, bootstrapPath)).to.eventually.equal( path.resolve(`${bootstrapPath}/${udid}_${platformVersion}.xctestrun`), ); - sandbox.assert.notCalled(fs.copyFile); + sandbox.assert.notCalled(fs.copyFile as any); }); it('should return platform based path without udid, copy them', async function () { @@ -181,6 +181,13 @@ describe('utils', function () { const wdaPort = getAdditionalRunContent(PLATFORM_NAME_TVOS, '9000'); expect(wdaPort.WebDriverAgentRunner_tvOS.EnvironmentVariables.USE_PORT).to.equal('9000'); }); + + it('should include max HTTP request body size if provided', function () { + const runContent = getAdditionalRunContent(PLATFORM_NAME_IOS, 8000, undefined, 1024); + expect( + runContent.WebDriverAgentRunner.EnvironmentVariables.MAX_HTTP_REQUEST_BODY_SIZE, + ).to.equal('1024'); + }); }); describe('#getXctestrunFileName', function () { diff --git a/test/unit/webdriveragent-specs.ts b/test/unit/webdriveragent-specs.ts index 1499c8077..92889df6a 100644 --- a/test/unit/webdriveragent-specs.ts +++ b/test/unit/webdriveragent-specs.ts @@ -2,20 +2,17 @@ import chai, {expect} from 'chai'; import chaiAsPromised from 'chai-as-promised'; import {BOOTSTRAP_PATH} from '../../lib/utils'; import {WebDriverAgent} from '../../lib/webdriveragent'; +import {selectWdaStartupStrategyName} from '../../lib/wda-strategies'; import * as utils from '../../lib/utils'; import path from 'node:path'; -import _ from 'lodash'; import sinon from 'sinon'; -import type {WebDriverAgentArgs, AppleDevice} from '../../lib/types'; +import type {WebDriverAgentArgs} from '../../lib/types'; chai.use(chaiAsPromised); const fakeConstructorArgs: WebDriverAgentArgs = { device: { udid: 'some-sim-udid', - simctl: {}, - devicectl: {}, - idb: null, }, platformVersion: '9', host: 'me', @@ -28,6 +25,28 @@ const customAgentPath = '/path/to/some/agent/WebDriverAgent.xcodeproj'; const customDerivedDataPath = '/path/to/some/agent/DerivedData/'; describe('WebDriverAgent', function () { + describe('startup strategy selection', function () { + it('should select an existing-url strategy for external WDA URLs', function () { + expect(selectWdaStartupStrategyName({webDriverAgentUrl: 'http://127.0.0.1:8100'})).to.equal( + 'existing-url', + ); + }); + + it('should select a simulator strategy for simulator sessions', function () { + expect(selectWdaStartupStrategyName({realDevice: false})).to.equal('simulator'); + }); + + it('should select a real-device preinstalled strategy for no-xcode real-device sessions', function () { + expect(selectWdaStartupStrategyName({realDevice: true, usePreinstalledWDA: true})).to.equal( + 'real-device-preinstalled', + ); + }); + + it('should select a real-device xcodebuild strategy for default real-device sessions', function () { + expect(selectWdaStartupStrategyName({realDevice: true})).to.equal('real-device-xcodebuild'); + }); + }); + describe('Constructor', function () { it('should have a default wda agent if not specified', function () { const agent = new WebDriverAgent(fakeConstructorArgs); @@ -35,43 +54,40 @@ describe('WebDriverAgent', function () { expect(agent.agentPath).to.eql(defaultAgentPath); }); it('should have custom wda bootstrap and default agent if only bootstrap specified', function () { - const agent = new WebDriverAgent( - _.defaults( - { - bootstrapPath: customBootstrapPath, - }, - fakeConstructorArgs, - ), - ); + const agent = new WebDriverAgent({ + ...fakeConstructorArgs, + bootstrapPath: customBootstrapPath, + }); expect(agent.bootstrapPath).to.eql(customBootstrapPath); expect(agent.agentPath).to.eql(path.resolve(customBootstrapPath, 'WebDriverAgent.xcodeproj')); }); it('should have custom wda bootstrap and agent if both specified', function () { - const agent = new WebDriverAgent( - _.defaults( - { - bootstrapPath: customBootstrapPath, - agentPath: customAgentPath, - }, - fakeConstructorArgs, - ), - ); + const agent = new WebDriverAgent({ + ...fakeConstructorArgs, + bootstrapPath: customBootstrapPath, + agentPath: customAgentPath, + }); expect(agent.bootstrapPath).to.eql(customBootstrapPath); expect(agent.agentPath).to.eql(customAgentPath); }); - it('should have custom derivedDataPath if specified', function () { - const agent = new WebDriverAgent( - _.defaults( - { - derivedDataPath: customDerivedDataPath, - }, - fakeConstructorArgs, - ), - ); + it('should have custom derivedDataPath if specified', async function () { + const agent = new WebDriverAgent({ + ...fakeConstructorArgs, + derivedDataPath: customDerivedDataPath, + }); if (agent.xcodebuild) { - expect(agent.xcodebuild.derivedDataPath).to.eql(customDerivedDataPath); + expect(await agent.retrieveDerivedDataPath()).to.eql(customDerivedDataPath); } }); + + it('should not create xcodebuild for real-device preinstalled sessions', function () { + const agent = new WebDriverAgent({ + ...fakeConstructorArgs, + realDevice: true, + usePreinstalledWDA: true, + }); + expect(() => agent.xcodebuild).to.throw('xcodebuild is not available'); + }); }); describe('launch', function () { @@ -118,7 +134,7 @@ describe('WebDriverAgent', function () { expect(agent.url.port).to.eql('8100'); expect(agent.url.hostname).to.eql('127.0.0.1'); - expect(agent.url.path).to.eql('/aabbccdd'); + expect(agent.url.pathname).to.eql('/aabbccdd'); if (agent.jwproxy) { expect(agent.jwproxy.server).to.eql('127.0.0.1'); expect(agent.jwproxy.port).to.eql(8100); @@ -222,53 +238,65 @@ describe('WebDriverAgent', function () { expect(agent.noSessionProxy.scheme).to.eql('https'); } }); + + it('should accept scheme-less webDriverAgentUrl values', function () { + const args = Object.assign({}, fakeConstructorArgs); + args.webDriverAgentUrl = 'localhost:8100/aabbccdd'; + const agent = new WebDriverAgent(args); + expect(agent.url.href).to.eql('http://localhost:8100/aabbccdd'); + (agent as any).setupProxies('mysession'); + if (agent.jwproxy) { + expect(agent.jwproxy.scheme).to.eql('http'); + } + }); + + it('should throw for invalid webDriverAgentUrl with explicit scheme', function () { + const args = Object.assign({}, fakeConstructorArgs); + args.webDriverAgentUrl = 'http://'; + const agent = new WebDriverAgent(args); + expect(() => agent.url).to.throw(); + }); }); describe('setupCaching()', function () { let wda: WebDriverAgent; let wdaStub: sinon.SinonStub; - let wdaStubUninstall: sinon.SinonStub; const getTimestampStub = sinon.stub(utils, 'getWDAUpgradeTimestamp'); beforeEach(function () { wda = new WebDriverAgent(fakeConstructorArgs); - wdaStub = sinon.stub(wda, 'getStatus'); - wdaStubUninstall = sinon.stub(wda as any, 'uninstall'); + wdaStub = sinon.stub(wda as any, 'getStatus'); }); afterEach(function () { - for (const stub of [wdaStub, wdaStubUninstall, getTimestampStub]) { + for (const stub of [wdaStub, getTimestampStub]) { if (stub) { stub.reset(); } } }); - it('should not call uninstall since no Running WDA', async function () { + it('should not cache when no WDA is running', async function () { wdaStub.callsFake(function () { return null; }); - wdaStubUninstall.callsFake(_.noop); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.be.undefined; expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; - expect(_.isUndefined(wda.webDriverAgentUrl)).to.be.true; + expect(wda.webDriverAgentUrl === undefined).to.be.true; }); - it('should not call uninstall since running WDA has only time', async function () { + it('should cache when running WDA has only time', async function () { wdaStub.callsFake(function () { return {build: {time: 'Jun 24 2018 17:08:21'}}; }); - wdaStubUninstall.callsFake(_.noop); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.equal('http://127.0.0.1:8100/'); expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; expect(wda.webDriverAgentUrl).to.equal('http://127.0.0.1:8100/'); }); - it('should call uninstall once since bundle id is not default without updatedWDABundleId capability', async function () { + it('should not cache when bundle id is not default without updatedWDABundleId capability', async function () { wdaStub.callsFake(function () { return { build: { @@ -277,15 +305,13 @@ describe('WebDriverAgent', function () { }, }; }); - wdaStubUninstall.callsFake(_.noop); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.be.undefined; expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.calledOnce).to.be.true; - expect(_.isUndefined(wda.webDriverAgentUrl)).to.be.true; + expect(wda.webDriverAgentUrl === undefined).to.be.true; }); - it('should call uninstall once since bundle id is different with updatedWDABundleId capability', async function () { + it('should not cache when bundle id is different with updatedWDABundleId capability', async function () { wdaStub.callsFake(function () { return { build: { @@ -295,21 +321,17 @@ describe('WebDriverAgent', function () { }; }); - wdaStubUninstall.callsFake(_.noop); - - await wda.setupCaching(); + expect(await wda.setupCaching()).to.be.undefined; expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.calledOnce).to.be.true; - expect(_.isUndefined(wda.webDriverAgentUrl)).to.be.true; + expect(wda.webDriverAgentUrl === undefined).to.be.true; }); - it('should not call uninstall since bundle id is equal to updatedWDABundleId capability', async function () { + it('should cache when bundle id is equal to updatedWDABundleId capability', async function () { wda = new WebDriverAgent({ ...fakeConstructorArgs, updatedWDABundleId: 'com.example.WebDriverAgent', }); - wdaStub = sinon.stub(wda, 'getStatus'); - wdaStubUninstall = sinon.stub(wda as any, 'uninstall'); + wdaStub = sinon.stub(wda as any, 'getStatus'); wdaStub.callsFake(function () { return { @@ -320,115 +342,53 @@ describe('WebDriverAgent', function () { }; }); - wdaStubUninstall.callsFake(_.noop); - - await wda.setupCaching(); + expect(await wda.setupCaching()).to.equal('http://127.0.0.1:8100/'); expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; expect(wda.webDriverAgentUrl).to.equal('http://127.0.0.1:8100/'); }); - it('should call uninstall if current revision differs from the bundled one', async function () { + it('should not cache if current revision differs from the bundled one', async function () { wdaStub.callsFake(function () { return {build: {upgradedAt: '1'}}; }); - getTimestampStub.callsFake(() => '2'); - wdaStubUninstall.callsFake(_.noop); + getTimestampStub.callsFake(async () => 2); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.be.undefined; expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.calledOnce).to.be.true; + expect(wda.webDriverAgentUrl === undefined).to.be.true; }); - it('should not call uninstall if current revision is the same as the bundled one', async function () { + it('should cache if current revision is the same as the bundled one', async function () { wdaStub.callsFake(function () { return {build: {upgradedAt: '1'}}; }); - getTimestampStub.callsFake(() => '1'); - wdaStubUninstall.callsFake(_.noop); + getTimestampStub.callsFake(async () => 1); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.equal('http://127.0.0.1:8100/'); expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; + expect(wda.webDriverAgentUrl).to.equal('http://127.0.0.1:8100/'); }); - it('should not call uninstall if current revision cannot be retrieved from WDA status', async function () { + it('should cache if current revision cannot be retrieved from WDA status', async function () { wdaStub.callsFake(function () { return {build: {}}; }); - getTimestampStub.callsFake(() => '1'); - wdaStubUninstall.callsFake(_.noop); + getTimestampStub.callsFake(async () => 1); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.equal('http://127.0.0.1:8100/'); expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; + expect(wda.webDriverAgentUrl).to.equal('http://127.0.0.1:8100/'); }); - it('should not call uninstall if current revision cannot be retrieved from the file system', async function () { + it('should cache if current revision cannot be retrieved from the file system', async function () { wdaStub.callsFake(function () { return {build: {upgradedAt: '1'}}; }); - getTimestampStub.callsFake(() => null); - wdaStubUninstall.callsFake(_.noop); + getTimestampStub.callsFake(async () => null); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.equal('http://127.0.0.1:8100/'); expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; - }); - - describe('uninstall', function () { - let device: AppleDevice; - let wda: WebDriverAgent; - let deviceGetBundleIdsStub: sinon.SinonStub; - let deviceRemoveAppStub: sinon.SinonStub; - - beforeEach(function () { - device = { - getUserInstalledBundleIdsByBundleName: () => {}, - removeApp: () => {}, - } as any; - wda = new WebDriverAgent({device} as WebDriverAgentArgs); - deviceGetBundleIdsStub = sinon.stub(device, 'getUserInstalledBundleIdsByBundleName'); - deviceRemoveAppStub = sinon.stub(device, 'removeApp'); - }); - - afterEach(function () { - for (const stub of [deviceGetBundleIdsStub, deviceRemoveAppStub]) { - if (stub) { - stub.reset(); - } - } - }); - - it('should not call uninstall', async function () { - deviceGetBundleIdsStub.callsFake(() => []); - - await (wda as any).uninstall(); - expect(deviceGetBundleIdsStub.calledOnce).to.be.true; - expect(deviceRemoveAppStub.notCalled).to.be.true; - }); - - it('should call uninstall once', async function () { - const uninstalledBundIds: string[] = []; - deviceGetBundleIdsStub.callsFake(() => ['com.appium.WDA1']); - deviceRemoveAppStub.callsFake((id: string) => uninstalledBundIds.push(id)); - - await (wda as any).uninstall(); - expect(deviceGetBundleIdsStub.calledOnce).to.be.true; - expect(deviceRemoveAppStub.calledOnce).to.be.true; - expect(uninstalledBundIds).to.eql(['com.appium.WDA1']); - }); - - it('should call uninstall twice', async function () { - const uninstalledBundIds: string[] = []; - deviceGetBundleIdsStub.callsFake(() => ['com.appium.WDA1', 'com.appium.WDA2']); - deviceRemoveAppStub.callsFake((id: string) => uninstalledBundIds.push(id)); - - await (wda as any).uninstall(); - expect(deviceGetBundleIdsStub.calledOnce).to.be.true; - expect(deviceRemoveAppStub.calledTwice).to.be.true; - expect(uninstalledBundIds).to.eql(['com.appium.WDA1', 'com.appium.WDA2']); - }); + expect(wda.webDriverAgentUrl).to.equal('http://127.0.0.1:8100/'); }); }); @@ -470,5 +430,74 @@ describe('WebDriverAgent', function () { expect(agent.bundleIdForXctest).to.equal('io.appium.wda.customsuffix'); }); }); + + describe('host operations', function () { + let sandbox: sinon.SinonSandbox; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('should delegate real-device preinstalled launch and terminate to injected host ops', async function () { + const launchPreinstalled = sandbox.stub().resolves(); + const terminate = sandbox.stub().resolves(); + const agent = new WebDriverAgent({ + ...fakeConstructorArgs, + device: {udid: 'real-device-udid'}, + realDevice: true, + usePreinstalledWDA: true, + wdaLocalPort: 9100, + updatedWDABundleId: 'io.appium.wda', + mjpegServerPort: 9200, + wdaBindingIP: '127.0.0.1', + maxHttpRequestBodySize: 1024, + hostOps: { + realDevicePreinstalled: { + launchPreinstalled, + terminate, + }, + }, + }); + sandbox.stub(agent as any, 'getStatus').resolves({build: 'data'}); + + await expect(agent.launch('sessionId')).to.eventually.eql({build: 'data'}); + sinon.assert.calledOnce(launchPreinstalled); + expect(launchPreinstalled.firstCall.args[0]).to.include({ + udid: 'real-device-udid', + bundleId: 'io.appium.wda.xctrunner', + wdaLocalPort: 9100, + }); + expect(launchPreinstalled.firstCall.args[0].env).to.eql({ + USE_PORT: 9100, + WDA_PRODUCT_BUNDLE_IDENTIFIER: 'io.appium.wda.xctrunner', + MJPEG_SERVER_PORT: 9200, + USE_IP: '127.0.0.1', + MAX_HTTP_REQUEST_BODY_SIZE: 1024, + }); + + await agent.quit(); + sinon.assert.calledOnceWithExactly(terminate, { + udid: 'real-device-udid', + bundleId: 'io.appium.wda.xctrunner', + }); + }); + + it('should require injected host ops for real-device preinstalled launch', async function () { + const agent = new WebDriverAgent({ + ...fakeConstructorArgs, + device: {udid: 'real-device-udid'}, + realDevice: true, + usePreinstalledWDA: true, + }); + + await expect(agent.launch('sessionId')).to.be.rejectedWith( + 'Host operations must be provided', + ); + }); + }); }); }); diff --git a/tsconfig.json b/tsconfig.json index 0db4e3637..8636ea8c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,14 +2,19 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@appium/tsconfig/tsconfig.json", "compilerOptions": { - "strict": false, // TODO: make this flag true "esModuleInterop": true, "outDir": "build", "types": ["node", "mocha"], - "checkJs": true + "checkJs": true, + "strict": true + }, + "ts-node": { + "transpileOnly": true, + "compilerOptions": { + "rootDir": "." + } }, "include": [ - "index.ts", "lib", "test" ]