Compare commits

..
Author SHA1 Message Date
github-actions 8018f8b70e chore: publish
- appium@3.5.0
 - @appium/base-driver@10.6.0
 - @appium/base-plugin@3.3.0
 - @appium/docutils@2.4.3
 - @appium/driver-test-support@1.2.3
 - @appium/eslint-config-appium-ts@3.2.2
 - @appium/execute-driver-plugin@6.0.4
 - @appium/fake-driver@6.2.0
 - @appium/fake-plugin@4.3.0
 - @appium/images-plugin@4.2.5
 - @appium/logger@2.0.8
 - @appium/opencv@4.1.5
 - @appium/plugin-test-support@1.2.3
 - @appium/relaxed-caps-plugin@2.2.3
 - @appium/schema@1.2.0
 - @appium/storage-plugin@1.1.5
 - @appium/support@7.2.3
 - @appium/test-support@4.1.5
 - @appium/types@1.5.0
 - @appium/universal-xml-plugin@2.1.4
2026-05-31 02:29:56 +00:00
renovate[bot] ea38864e95 chore(execute-driver-plugin): update dependency webdriverio to v9.27.2 (#22326)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-30 09:32:17 +00:00
Mykola Mokhnach cf79b3283a feat(base-driver): Ditch lodash (#22324) 2026-05-30 08:13:10 +02:00
Mykola Mokhnach 7c60cee787 chore(driver-test-support): Ditch lodash (#22323) 2026-05-30 08:12:55 +02:00
Mykola Mokhnach bfac5ab9ec chore(fake-driver,fake-plugin): Ditch lodash (#22322) 2026-05-29 18:58:32 +02:00
Mykola Mokhnach d329ecd7f7 chore(support): Deprecate more unused helpers (#22321) 2026-05-29 18:58:19 +02:00
Mykola Mokhnach aa85993c01 chore(support): Simplify plist parsing (#22320) 2026-05-29 18:58:09 +02:00
Mykola Mokhnach 3c34ec1a04 chore(support): Deprecate sharp-related primitives (#22318) 2026-05-29 09:40:29 +02:00
renovate[bot] 2cd71fc45d chore(eslint-config-appium-ts): update dependency typescript-eslint to v8.60.0 (#22319)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-29 09:15:24 +02:00
Mykola Mokhnach bab02cc7c1 chore(docutils): Ditch lodash (#22317) 2026-05-29 09:13:46 +02:00
Mykola Mokhnach 66b38e31ab chore(execute-driver-plugin): Ditch lodash (#22316) 2026-05-28 09:40:30 +02:00
Mykola Mokhnach 66eeaa2791 chore(images-plugin): Ditch lodash (#22312) 2026-05-28 07:41:42 +02:00
Mykola Mokhnach a1fc1c0e18 chore(logger): Ditch lodash (#22313) 2026-05-28 07:40:29 +02:00
Mykola Mokhnach ccbe297d3a chore(relaxed-caps-plugin): Ditch lodash (#22311) 2026-05-27 20:37:25 +02:00
renovate[bot] 101b31f35d chore(storage-plugin): update dependency ws to v8.21.0 (#22310)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 08:30:57 +00:00
Mykola Mokhnach b01bed2d2e chore(opencv): Ditch lodash (#22309) 2026-05-27 10:02:35 +02:00
Mykola Mokhnach 6b9ad08246 chore(storage-plugin): Ditch lodash (#22308) 2026-05-27 10:02:26 +02:00
dependabot[bot] 2694d789c4 chore(deps-dev): bump tmp in the npm_and_yarn group across 1 directory (#22307)
Bumps the npm_and_yarn group with 1 update in the / directory: [tmp](https://github.com/raszi/node-tmp).


Updates `tmp` from 0.2.5 to 0.2.6
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.5...v0.2.6)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.6
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 06:44:58 +02:00
Mykola Mokhnach 58fad654c3 chore(test-support): Ditch lodash (#22306) 2026-05-27 06:22:52 +02:00
Mykola Mokhnach 0d1271f812 chore(universal-xml-plugin): Ditch lodash (#22305) 2026-05-27 06:22:40 +02:00
renovate[bot] c79225f8cf chore(support): update dependency shell-quote to v1.8.4 (#22304)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 19:25:32 +02:00
renovate[bot] 2afd0e651c chore(deps): update dependency npm-run-all2 to v9.0.1 (#22303)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 16:04:28 +00:00
renovate[bot] 075466243d chore(support): update dependency yauzl to v3.3.1 (#22302)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 08:26:07 +00:00
Mykola Mokhnach abb83bde62 feat(appium): Ditch bluebird (#22301) 2026-05-25 08:13:07 +02:00
Mykola Mokhnach eb139abf97 chore(test-support): Remove unused bluebird dependency (#22300) 2026-05-25 08:12:54 +02:00
renovate[bot] 377011bdb3 chore(eslint-config-appium-ts): update dependency eslint-plugin-jsdoc to v63 (#22295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 07:50:17 +02:00
renovate[bot] 1fc8582327 chore(support): update dependency semver to v7.8.1 (#22299)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 07:42:10 +02:00
renovate[bot] f6a96d6647 chore(deps): update dependency mocha to v11.7.6 (#22298)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 07:41:57 +02:00
renovate[bot] 04b408aace chore(storage-plugin): update dependency lru-cache to v11.5.0 (#22294)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 07:33:05 +02:00
renovate[bot] f600982578 chore(deps): update dependency npm-run-all2 to v9 (#22291)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 07:31:57 +02:00
dependabot[bot] c8b9321667 chore(deps): bump qs in the npm_and_yarn group across 1 directory (#22292)
Bumps the npm_and_yarn group with 1 update in the / directory: [qs](https://github.com/ljharb/qs).


Updates `qs` from 6.14.2 to 6.15.2
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.2...v6.15.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.15.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 07:31:06 +02:00
Mykola Mokhnach c9b7eac365 feat(base-driver): Ditch bluebird (#22293) 2026-05-23 08:28:06 +02:00
renovate[bot] efbf7115cc chore(eslint-config-appium-ts): update dependency eslint-plugin-mocha to v11.3.0 (#22290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 16:10:15 +00:00
renovate[bot] 679ec8864c chore(eslint-config-appium-ts): update dependency typescript-eslint to v8.59.4 (#22289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 09:35:52 +00:00
renovate[bot] 094daa24be chore(storage-plugin): update dependency lru-cache to v11.4.0 (#22288)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 14:00:29 +00:00
renovate[bot] 9c3aaa1056 chore(deps): update dependency eslint to v10.4.0 (#22286)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 10:55:46 +00:00
github-actions[bot]andmykola-mokhnach e58253033d docs(appium): Update documentation translations (#22285)
Co-authored-by: mykola-mokhnach <7767781+mykola-mokhnach@users.noreply.github.com>
2026-05-18 23:46:57 -07:00
Mykola Mokhnach 25d922b7d3 feat: make assignIpc internal (#22278) 2026-05-18 07:35:51 +02:00
Mykola Mokhnach 0264775ecb feat: Limit the maximum count of topics per session and make it configurable (#22279) 2026-05-18 07:22:48 +02:00
Mykola Mokhnach f5b193ccf0 chore(base-driver): Simplify async generator logic in IpcSubscription (#22280) 2026-05-18 07:22:32 +02:00
renovate[bot] 6d88f54918 chore(support): update dependency axios to v1.16.1 (#22283)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-17 13:25:41 +00:00
renovate[bot] 95c4091b8b chore(support): update dependency archiver to v8 (#22267) 2026-05-16 15:25:52 +02:00
renovate[bot] ecf927395d chore(storage-plugin): update dependency ws to v8.20.1 (#22281)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-16 09:44:23 +00:00
renovate[bot] e1e228380f chore(universal-xml-plugin): update dependency fast-xml-parser to v5.8.0 (#22277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 15:31:01 +00:00
renovate[bot] 74170ec575 chore(eslint-config-appium-ts): update dependency typescript-eslint to v8.59.3 (#22275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 12:43:00 +00:00
renovate[bot] 3f9a2ea18f chore(docutils): update dependency yaml to v2.9.0 (#22276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 09:39:13 +00:00
renovate[bot] 2ef20b988b chore(deps): update dependency @types/node to v24.12.4 (#22274)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 07:51:31 +00:00
github-actions[bot]andmykola-mokhnach 1dffc4e833 docs(appium): Update documentation translations (#22273)
Co-authored-by: mykola-mokhnach <7767781+mykola-mokhnach@users.noreply.github.com>
2026-05-14 22:45:04 -07:00
Jonathan Lipps 97c879cdd0 feat: add IPC channels per session for drivers and plugins to communicate (#22211)
* feat: add IPC channels per session for drivers and plugins to communicate (fix #22206)

* lint

* export AppiumIpc

* fix test issues

* import AppiumIpc directly from basedriver

* fakedriver is used directly outside of appium so can't rely on existence of IPC

* make ipc functions async with lock and better types

* fix assignIpc types

* deduplicate session object deletion

* ipcGuard -> withIpc

* address various review comments

* lint

* address copilot and other review comments

* store only last message. change model to use subscription object and async generator

* enforce max message size in bytes

* fix tests and call the message payload 'data' for clarity

* lint

* make assignIpc optional on drivers for backwards compatibility

* add error handling in startClock since it is usually not awaited

* add docs

* remove todo comment

* apply copilot and eglitise suggestions

* add typedoc

* add error handling to onIpcInit

* apply more suggestions

* fix schema unit test

* apply more suggestions

* add links to ipc examples

* make minimum ipc message size 1 instead of 0

* ensure publisher names are friendly to consumers

* fix ipc message time unit

* use already included sizeof for objs

* just pass error obj to log

* be more typescriptey about our ipc guard

* make max obj size part of opts for appiumipc

* let unsubscribe return whether it worked

* improve ipc types and shapes

* apply master package-lock.json

* update schema config

* use javascript naming conventions

* clarify distinction between message and payload (data)

* ensure async iterator loop ends when unsubscribe is called

* ensure the right things happen when you unsubscribe

* make require ipc more ergonomic

* remove unnecessary async locks and de-asyncify methods

* simplify interface and just use subscriber/plugin names

* fix docs

* fix lint

* document isActive

* add note about topic namespace

* use sleep instead of delay

* restrict the ipc payload data type to something that can be cloned

* clarify topic namespacing recommendation

* make sure listeners and promises are cleaned up in async iterator

* clean up ipc internal names

* readonly

* address review comments

* make ipcmessage readonly

* readonly cause why not
2026-05-14 16:56:02 -07:00
renovate[bot] 8300e115b2 chore(docutils): update dependency mkdocs-git-revision-date-localized-plugin to v1.5.2 (#22270)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-13 13:38:48 +00:00
renovate[bot] ea186eae17 chore(support): update dependency semver to v7.8.0 (#22266)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 14:46:17 +00:00
renovate[bot] 07466e02f7 chore(support): update dependency asyncbox to v6.3.0 (#22265)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 11:34:37 +00:00
renovate[bot] a91f4a718b chore(deps): update dependency @types/node to v24.12.3 (#22263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 19:15:58 +02:00
renovate[bot] f53e443d64 chore(eslint-config-appium-ts): update dependency @eslint/compat to v2.1.0 (#22264)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 15:37:16 +00:00
renovate[bot] fc11de2bda chore(test-support): update dependency sinon to v22 (#22262)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 20:35:59 +02:00
renovate[bot] 82343effc4 chore(eslint-config-appium-ts): update dependency eslint-plugin-n to v18 (#22261)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 20:35:28 +02:00
renovate[bot] 59a460949a chore(universal-xml-plugin): update dependency fast-xml-parser to v5.7.3 (#22260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 10:19:43 +02:00
renovate[bot] b4dd22512c chore(eslint-config-appium-ts): update dependency typescript-eslint to v8.59.2 (#22258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 08:48:04 +02:00
renovate[bot] a18700fed6 chore(storage-plugin): update dependency lru-cache to v11.3.6 (#22259)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 08:47:54 +02:00
dependabot[bot] 2f312722d7 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#22257)
Bumps the npm_and_yarn group with 2 updates in the / directory: [fast-uri](https://github.com/fastify/fast-uri) and [fast-xml-builder](https://github.com/NaturalIntelligence/fast-xml-builder).


Updates `fast-uri` from 3.0.1 to 3.1.2
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.0.1...v3.1.2)

Updates `fast-xml-builder` from 1.1.5 to 1.2.0
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-builder/blob/main/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-builder/compare/v1.1.5...v1.2.0)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: fast-xml-builder
  dependency-version: 1.2.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 22:43:31 +03:00
github-actions f5369c65a8 chore: publish
- appium@3.4.2
 - @appium/base-driver@10.5.2
 - @appium/base-plugin@3.2.4
 - @appium/docutils@2.4.2
 - @appium/fake-plugin@4.2.4
 - @appium/images-plugin@4.2.4
 - @appium/storage-plugin@1.1.4
 - @appium/support@7.2.2
 - @appium/test-support@4.1.4
2026-05-07 18:32:52 +00:00
Mykola Mokhnach 8c6c19982e fix(support): accept Uint8Array/ArrayBuffer in plist parse path (#22256) 2026-05-07 20:26:25 +02:00
Mykola Mokhnach e39017feac ci: Restore publish fallback script (#22255) 2026-05-07 20:25:25 +02:00
github-actions fd6dbe9eff chore: publish
- appium@3.4.1
 - @appium/base-driver@10.5.1
 - @appium/base-plugin@3.2.3
 - @appium/docutils@2.4.1
 - @appium/eslint-config-appium-ts@3.2.1
 - @appium/fake-plugin@4.2.3
 - @appium/images-plugin@4.2.3
 - @appium/storage-plugin@1.1.3
 - @appium/support@7.2.1
 - @appium/test-support@4.1.3
2026-05-06 20:38:58 +00:00
dependabot[bot] c18b535d42 chore(deps): bump basic-ftp in the npm_and_yarn group across 1 directory (#22253)
Bumps the npm_and_yarn group with 1 update in the / directory: [basic-ftp](https://github.com/patrickjuchli/basic-ftp).


Updates `basic-ftp` from 5.3.0 to 5.3.1
- [Release notes](https://github.com/patrickjuchli/basic-ftp/releases)
- [Changelog](https://github.com/patrickjuchli/basic-ftp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/patrickjuchli/basic-ftp/compare/v5.3.0...v5.3.1)

---
updated-dependencies:
- dependency-name: basic-ftp
  dependency-version: 5.3.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 23:24:12 +03:00
Edgars Eglītis 9045dfcefa ci: tune release workflows (#22252)
* ci: tune release workflows

* revert splitting workflows
2026-05-06 23:16:35 +03:00
Edgars Eglītis 98a1735913 fix(eslint-config-appium-ts): set new ESLint 10 rules to 'warn' (#22251) 2026-05-06 22:36:19 +03:00
Mykola Mokhnach 3229e74d3d fix(support): Avoid exceptions in unwrapElement (#22250) 2026-05-06 21:28:12 +02:00
179 changed files with 3559 additions and 1723 deletions
+2 -3
View File
@@ -67,7 +67,7 @@ jobs:
exit $release_exit_code
fi
echo "Detected existing tag failure for ${existing_tag}; attempting publish recovery from tagged commit"
echo "Detected existing tag failure for ${existing_tag}; attempting scoped publish recovery from tagged commit"
git fetch --tags --force origin
tagged_commit="$(git rev-list -n 1 "$existing_tag")"
if [ -z "$tagged_commit" ]; then
@@ -111,7 +111,7 @@ jobs:
scoped_exit_code=$?
set -e
if [ $scoped_exit_code -ne 0 ]; then
echo "Scoped retry failed for ${pkg_name}"
echo "Scoped publish exited non-zero for ${pkg_name}; will verify on npm"
fi
done
@@ -135,4 +135,3 @@ jobs:
exit $release_exit_code
fi
fi
+4 -1
View File
@@ -1,4 +1,7 @@
name: Appium Server Version
# This job only publishes new releases on GitHub and updates the changelog, without publishing to NPM.
# It can be used if the standard release workflow failed and *only* published to NPM.
name: Appium Server Version (Without NPM Publish)
on:
workflow_dispatch:
+628 -588
View File
File diff suppressed because it is too large Load Diff
+11 -12
View File
@@ -88,7 +88,6 @@
"@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"@tsconfig/node20": "20.1.9",
"@types/archiver": "7.0.0",
"@types/argparse": "2.0.17",
"@types/async-lock": "1.4.2",
"@types/base64-stream": "1.0.5",
@@ -105,7 +104,7 @@
"@types/mocha": "10.0.10",
"@types/mv": "2.1.4",
"@types/ncp": "2.0.8",
"@types/node": "24.12.2",
"@types/node": "24.12.4",
"@types/pluralize": "0.0.33",
"@types/semver": "7.7.1",
"@types/serve-favicon": "2.5.7",
@@ -116,18 +115,18 @@
"@types/ws": "8.18.1",
"@types/xmldom": "0.1.34",
"@types/yargs": "17.0.35",
"asyncbox": "6.2.0",
"asyncbox": "6.3.0",
"chai": "6.2.2",
"chai-as-promised": "8.0.2",
"conventional-changelog-conventionalcommits": "7.0.2",
"cpy-cli": "7.0.0",
"cross-env": "10.1.0",
"eslint": "10.3.0",
"eslint": "10.4.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-mocha": "11.2.0",
"eslint-plugin-n": "17.24.0",
"eslint-plugin-mocha": "11.3.0",
"eslint-plugin-n": "18.0.1",
"eslint-plugin-promise": "7.3.0",
"finalhandler": "2.1.1",
"get-port": "7.2.0",
@@ -137,22 +136,22 @@
"midnight-smoker": "8.0.0",
"mjpeg-consumer": "2.0.0",
"mjpeg-server": "0.3.1",
"mocha": "11.7.5",
"npm-run-all2": "8.0.4",
"mocha": "11.7.6",
"npm-run-all2": "9.0.1",
"prettier": "3.8.3",
"rewiremock": "3.14.6",
"rimraf": "6.1.3",
"serve-static": "2.2.1",
"sinon": "21.1.2",
"sinon": "22.0.0",
"sinon-chai": "4.0.1",
"sync-monorepo-packages": "1.0.2",
"teen_process": "4.1.3",
"ts-node": "10.9.2",
"tsd": "0.33.0",
"typescript": "6.0.3",
"typescript-eslint": "8.59.1",
"webdriverio": "9.27.1",
"ws": "8.20.0"
"typescript-eslint": "8.60.0",
"webdriverio": "9.27.2",
"ws": "8.21.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
+28
View File
@@ -3,6 +3,34 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.5.0](https://github.com/appium/appium/compare/appium@3.4.2...appium@3.5.0) (2026-05-31)
### Features
* add IPC channels per session for drivers and plugins to communicate ([#22211](https://github.com/appium/appium/issues/22211)) ([97c879c](https://github.com/appium/appium/commit/97c879cdd02e6fcccaaea45a8d1a7088bed4ae66)), closes [#22206](https://github.com/appium/appium/issues/22206)
* **appium:** Ditch bluebird ([#22301](https://github.com/appium/appium/issues/22301)) ([abb83bd](https://github.com/appium/appium/commit/abb83bde62e8beefdd9483a276ef2aa888722245))
* Limit the maximum count of topics per session and make it configurable ([#22279](https://github.com/appium/appium/issues/22279)) ([0264775](https://github.com/appium/appium/commit/0264775ecb8ecaca9dac2eae47784da02a653255))
* make assignIpc internal ([#22278](https://github.com/appium/appium/issues/22278)) ([25d922b](https://github.com/appium/appium/commit/25d922b7d3062c306c1631d6f5c227600ddd226a))
## [3.4.2](https://github.com/appium/appium/compare/appium@3.4.1...appium@3.4.2) (2026-05-07)
**Note:** Version bump only for package appium
## [3.4.1](https://github.com/appium/appium/compare/appium@3.4.0...appium@3.4.1) (2026-05-06)
**Note:** Version bump only for package appium
## [3.4.0](https://github.com/appium/appium/compare/appium@3.3.1...appium@3.4.0) (2026-05-06)
@@ -885,3 +885,118 @@ this.onUnexpectedShutdown(handler)
`handler` should be a function which receives an error object (representing the reason for the
unexpected shutdown).
### Send messages to plugins running on the same session
Appium's driver and plugin architecture means that a single driver and any number of plugins can be
running together for a current session. The driver and plugins don't really know anything about
each other though, which can make coordination potentially difficult. Without creating any kind of
tight coupling between drivers or plugins, it's possible for drivers and plugins to communicate
along a pub/sub style message bus. With Appium's IPC feature, "message" means a combination of
"data" (the data intended to be published or received), and metadata (timestamp, the name of the
publisher, etc...).
Drivers and plugins can implement an instance method called `async onIpcInit`, in which they will
know that the IPC channel for the session is now active. At this point, they have access to the
following methods:
#### `ipcSubscribe<T>(topic: string): IIpcSubscription`
This method allows a driver or plugin to subscribe to a topic on the IPC channel for the session.
Topics are simply strings. To coordinate effectively, drivers or plugins would use the same topic
string (so this would be something advertised in the driver/plugin documentation).
The result of this call is an `IpcSubscription` object that allows:
- Looping through messages on the topic via an async generator
- Hooking up an event handler for new messages
- Getting the last message sent on the topic
- Publishing a message to the topic
- Unsubscribing from the topic
#### `IpcSubscription` object
Here is an example of how subscribing and use of these subscription methods might work:
```ts
async onIpcInit() {
// we know at this point in our driver/plugin lifecycle that we can use IPC methods
type MyMessageData = {myData: string}; // let's say the message data for this channel has this shape
const subscription = this.ipcSubscribe<MyMessageData>('coolTopic');
// listen for new messages on topic
subscription.on('message', (message) => {
console.log(message.data); // {myData: 'something'}
});
// publish data to the topic; might throw if the data is larger than configured max size, or we
// are no longer subscribed
await subscription.publish({myData: 'hi'});
// get last message sent to topic
const {data} = subscription.getMessage() ?? {data: null};
// data = {myData: 'hi'}
// unsubscribe
subscription.unsubscribe(); // returns true if we unsubscribed and false if no subscription existed
}
```
With a subscription object, we can also iterate async over it to receive new messages:
```ts
for await (const message of subscription) {
// here we have a message and can unwrap its data
}
```
Note that the iteration will stop when the subscription ends (i.e., when `unsubscribe` is called).
You can also check whether the subscription is still active (if your subscribe and unsubscribe
logic is not locally connected):
```ts
if (subscription.isActive) {
// we know we have not yet unsubscribed
}
```
If the subscription is not active, attempts to call `subscription.publish` will throw an error.
#### `IpcMessage` object
When you get a message on a topic, it has the following shape:
```ts
export type IpcMessage<T> = {
publisher: string, // the id of the publishing object (typically of the form <ClassName>@<uniqueObjectId>)
timestampMs: number, // when the message was published, in ms since epoch
topic: string, // the topic the message was published on
data: T, // the arbitrary message data. Must be serializable and a valid argument to 'structuredClone()'
};
```
#### IPC Notes
There are some important things to keep in mind when using Appium's IPC feature:
- IPC is only available once `onIpcInit` has been called, at the end of the `createSession` flow.
It cannot be used statically or in `createSession` hooks
- In sum, IPC is only for use during a session (this is also to prevent drivers/plugins from
accessing or reading data sent on IPC channels in other sessions.)
- The default max size of an IPC message is 1MB. This can be configured by the server-admin by
using the `--max-ipc-data-size` arg (value is a number in bytes).
- If a message exceeds the configured size, any call to `publish` methods will throw, so be
sure to cover this case in your error handling.
- The default max number of IPC topics per session is 1000. This can be configured by the
server-admin using the `--max-ipc-topics` arg.
- If the topic limit is reached, calls to `subscribe` or `publish` on a new topic will throw, so
be sure to cover this case in your error handling.
- Topic names exist within a global namespace per session. So if you don't want to clash with other
drivers or plugins, make sure you prefix your topic names with something unique, akin to vendor
prefixes in the WebDriver standard (for example: `fake-driver:some-topic`). We recommend the
format `<namespace>:<topic>`.
To see examples of IPC in action, check out Appium's own
[FakeDriver](https://github.com/appium/appium/blob/master/packages/fake-driver/lib/driver.ts) and
[FakePlugin](https://github.com/appium/appium/blob/master/packages/fake-plugin/lib/plugin.ts)
extensions, which showcase several ways of using IPC messages.
@@ -512,3 +512,12 @@ async onUnexpectedShutdown(driver, cause) {
}
}
```
### Send messages to the driver or other plugins running on the same session
Even though drivers and plugins don't know anything about each other, it's possible for them to
send messages that other drivers or plugins can listen for, within the context of the same session,
using an IPC channel. Given that the developer instructions for this feature are the same for
drivers and plugins, please head over to the [IPC development docs for
drivers](./build-drivers.md#send-messages-to-plugins-running-on-the-same-session) at the driver
development guide.
@@ -48,6 +48,8 @@ appium
|`--log-no-colors`|Disable colors in the server log|boolean|`false`|
|`--log-timestamp`|Show timestamps in the server log|boolean|`false`|
|`--long-stacktrace`|Add long stack traces to log entries. Recommended for debugging only.|boolean|`false`|
|`--max-ipc-data-size`|Maximum byte size of IPC message objects.|integer|`1048576` (1MB)|
|`--max-ipc-topics`|Maximum number of IPC topics per session.|integer|`1000`|
|`--no-perms-check`|Skip various permission checks on the server startup|boolean|`false`|
|`--nodeconfig`|JSON configuration for registering Appium as a node with Selenium Grid 3|object||
|`--plugin`|Plugin-specific configuration. Keys should correspond to plugin package names|object||
@@ -891,3 +891,120 @@ this.onUnexpectedShutdown(handler)
`handler` should be a function which receives an error object (representing the reason for the
unexpected shutdown).
### Send messages to plugins running on the same session
Appium's driver and plugin architecture means that a single driver and any number of plugins can be
running together for a current session. The driver and plugins don't really know anything about
each other though, which can make coordination potentially difficult. Without creating any kind of
tight coupling between drivers or plugins, it's possible for drivers and plugins to communicate
along a pub/sub style message bus. With Appium's IPC feature, "message" means a combination of
"data" (the data intended to be published or received), and metadata (timestamp, the name of the
publisher, etc...).
Drivers and plugins can implement an instance method called `async onIpcInit`, in which they will
know that the IPC channel for the session is now active. At this point, they have access to the
following methods:
#### `ipcSubscribe<T>(topic: string): IIpcSubscription`
This method allows a driver or plugin to subscribe to a topic on the IPC channel for the session.
Topics are simply strings. To coordinate effectively, drivers or plugins would use the same topic
string (so this would be something advertised in the driver/plugin documentation).
The result of this call is an `IpcSubscription` object that allows:
- Looping through messages on the topic via an async generator
- Hooking up an event handler for new messages
- Getting the last message sent on the topic
- Publishing a message to the topic
- Unsubscribing from the topic
#### `IpcSubscription` object
Here is an example of how subscribing and use of these subscription methods might work:
```ts
async onIpcInit() {
// we know at this point in our driver/plugin lifecycle that we can use IPC methods
type MyMessageData = {myData: string}; // let's say the message data for this channel has this shape
const subscription = this.ipcSubscribe<MyMessageData>('coolTopic');
// listen for new messages on topic
subscription.on('message', (message) => {
console.log(message.data); // {myData: 'something'}
});
// publish data to the topic; might throw if the data is larger than configured max size, or we
// are no longer subscribed
await subscription.publish({myData: 'hi'});
// get last message sent to topic
const {data} = subscription.getMessage() ?? {data: null};
// data = {myData: 'hi'}
// unsubscribe
subscription.unsubscribe(); // returns true if we unsubscribed and false if no subscription existed
}
```
With a subscription object, we can also iterate async over it to receive new messages:
```ts
for await (const message of subscription) {
// here we have a message and can unwrap its data
}
```
Note that the iteration will stop when the subscription ends (i.e., when `unsubscribe` is called).
You can also check whether the subscription is still active (if your subscribe and unsubscribe
logic is not locally connected):
```ts
if (subscription.isActive) {
// we know we have not yet unsubscribed
}
```
If the subscription is not active, attempts to call `subscription.publish` will throw an error.
#### `IpcMessage` object
When you get a message on a topic, it has the following shape:
```ts
export type IpcMessage<T> = {
publisher: string, // the id of the publishing object (typically of the form <ClassName>@<uniqueObjectId>)
timestampMs: number, // when the message was published, in ms since epoch
topic: string, // the topic the message was published on
data: T, // the arbitrary message data. Must be serializable and a valid argument to 'structuredClone()'
};
```
#### IPC Notes
There are some important things to keep in mind when using Appium's IPC feature:
- IPC is only available once `onIpcInit` has been called, at the end of the `createSession` flow.
It cannot be used statically or in `createSession` hooks
- In sum, IPC is only for use during a session (this is also to prevent drivers/plugins from
accessing or reading data sent on IPC channels in other sessions.)
- The default max size of an IPC message is 1MB. This can be configured by the server-admin by
using the `--max-ipc-data-size` arg (value is a number in bytes).
- If a message exceeds the configured size, any call to `publish` methods will throw, so be
sure to cover this case in your error handling.
- The default max number of IPC topics per session is 1000. This can be configured by the
server-admin using the `--max-ipc-topics` arg.
- If the topic limit is reached, calls to `subscribe` or `publish` on a new topic will throw, so
be sure to cover this case in your error handling.
- Topic names exist within a global namespace per session. So if you don't want to clash with other
drivers or plugins, make sure you prefix your topic names with something unique, akin to vendor
prefixes in the WebDriver standard (for example: `fake-driver:some-topic`). We recommend the
format `<namespace>:<topic>`.
To see examples of IPC in action, check out Appium's own
[FakeDriver](https://github.com/appium/appium/blob/master/packages/fake-driver/lib/driver.ts) and
[FakePlugin](https://github.com/appium/appium/blob/master/packages/fake-plugin/lib/plugin.ts)
extensions, which showcase several ways of using IPC messages.
@@ -236,13 +236,13 @@ upstream server. In this case, we might run into a problem, because the plugin n
handle that command! The way Appium handles this is as follows:
1. When a command comes in, before deciding whether to proxy the command, the main protocol handler
checks to see whether a plugin would handle the command.
checks to see whether a plugin would handle the command.
2. If a plugin would _not_ handle the command--all proceeds as normal, and the request is either
proxied or not, based on the driver's proxy mode.
proxied or not, based on the driver's proxy mode.
3. If a plugin _would_ handle the command, then the proxy behavior is skipped for the time being,
and wrapped up as the `next` function sent to the plugin. So if you have a command handler in
your plugin, and you want to be sure that the default driver proxying _does_ take place, then
simply `await next()` instead of (or in addition to) whatever your plugin handler is doing.
and wrapped up as the `next` function sent to the plugin. So if you have a command handler in
your plugin, and you want to be sure that the default driver proxying _does_ take place, then
simply `await next()` instead of (or in addition to) whatever your plugin handler is doing.
### Throw WebDriver-specific errors
@@ -331,7 +331,7 @@ drivers, you can create new commands in one of two ways, just as is possible for
1. Extending the WebDriver protocol and creating client-side plugins to access the extensions
2. Extending the WebDriver BiDi protocol with new modules and methods, accessed from a client via the BiDi interface
3. Overloading the Execute Script command by defining Execute
Methods
Methods
#### Extending the HTTP Protocol
@@ -458,10 +458,10 @@ inside the plugin class:
1. The `executeMethodMap`, defined in just the same way as for drivers
2. The implementation of the command method as defined in `executeMethodMap` (in this case,
`plugMeIn`)
`plugMeIn`)
3. The overriding/handling of the `execute` command. Just like any plugin command handlers, the
first two arguments are `next` and `driver`, followed by the script name and args. `BasePlugin`
implements a helper method which we can simply call with all of these arguments.
first two arguments are `next` and `driver`, followed by the script name and args. `BasePlugin`
implements a helper method which we can simply call with all of these arguments.
Overriding Execute Methods from drivers works as you'd expect: if your plugin defines an Execute
Method with the same name as that of a driver, your command (in this case `plugMeIn`) will be
@@ -518,3 +518,12 @@ async onUnexpectedShutdown(driver, cause) {
}
}
```
### Send messages to the driver or other plugins running on the same session
Even though drivers and plugins don't know anything about each other, it's possible for them to
send messages that other drivers or plugins can listen for, within the context of the same session,
using an IPC channel. Given that the developer instructions for this feature are the same for
drivers and plugins, please head over to the IPC development docs for
drivers at the driver
development guide.
@@ -48,6 +48,8 @@ appium
| `--log-no-colors` | Disable colors in the server log | boolean | `false` |
| `--log-timestamp` | Show timestamps in the server log | boolean | `false` |
| `--long-stacktrace` | Add long stack traces to log entries. Recommended for debugging only. | boolean | `false` |
| `--max-ipc-data-size` | Maximum byte size of IPC message objects. | integer | `1048576` (1MB) |
| `--max-ipc-topics` | Maximum number of IPC topics per session. | integer | `1000` |
| `--no-perms-check` | Skip various permission checks on the server startup | boolean | `false` |
| `--nodeconfig` | JSON configuration for registering Appium as a node with Selenium Grid 3 | object | |
| `--plugin` | Plugin-specific configuration. Keys should correspond to plugin package names | object | |
@@ -891,3 +891,120 @@ this.onUnexpectedShutdown(handler)
`handler` should be a function which receives an error object (representing the reason for the
unexpected shutdown).
### Send messages to plugins running on the same session
Appium's driver and plugin architecture means that a single driver and any number of plugins can be
running together for a current session. The driver and plugins don't really know anything about
each other though, which can make coordination potentially difficult. Without creating any kind of
tight coupling between drivers or plugins, it's possible for drivers and plugins to communicate
along a pub/sub style message bus. With Appium's IPC feature, "message" means a combination of
"data" (the data intended to be published or received), and metadata (timestamp, the name of the
publisher, etc...).
Drivers and plugins can implement an instance method called `async onIpcInit`, in which they will
know that the IPC channel for the session is now active. At this point, they have access to the
following methods:
#### `ipcSubscribe<T>(topic: string): IIpcSubscription`
This method allows a driver or plugin to subscribe to a topic on the IPC channel for the session.
Topics are simply strings. To coordinate effectively, drivers or plugins would use the same topic
string (so this would be something advertised in the driver/plugin documentation).
The result of this call is an `IpcSubscription` object that allows:
- Looping through messages on the topic via an async generator
- Hooking up an event handler for new messages
- Getting the last message sent on the topic
- Publishing a message to the topic
- Unsubscribing from the topic
#### `IpcSubscription` object
Here is an example of how subscribing and use of these subscription methods might work:
```ts
async onIpcInit() {
// we know at this point in our driver/plugin lifecycle that we can use IPC methods
type MyMessageData = {myData: string}; // let's say the message data for this channel has this shape
const subscription = this.ipcSubscribe<MyMessageData>('coolTopic');
// listen for new messages on topic
subscription.on('message', (message) => {
console.log(message.data); // {myData: 'something'}
});
// publish data to the topic; might throw if the data is larger than configured max size, or we
// are no longer subscribed
await subscription.publish({myData: 'hi'});
// get last message sent to topic
const {data} = subscription.getMessage() ?? {data: null};
// data = {myData: 'hi'}
// unsubscribe
subscription.unsubscribe(); // returns true if we unsubscribed and false if no subscription existed
}
```
With a subscription object, we can also iterate async over it to receive new messages:
```ts
for await (const message of subscription) {
// here we have a message and can unwrap its data
}
```
Note that the iteration will stop when the subscription ends (i.e., when `unsubscribe` is called).
You can also check whether the subscription is still active (if your subscribe and unsubscribe
logic is not locally connected):
```ts
if (subscription.isActive) {
// we know we have not yet unsubscribed
}
```
If the subscription is not active, attempts to call `subscription.publish` will throw an error.
#### `IpcMessage` object
When you get a message on a topic, it has the following shape:
```ts
export type IpcMessage<T> = {
publisher: string, // the id of the publishing object (typically of the form <ClassName>@<uniqueObjectId>)
timestampMs: number, // when the message was published, in ms since epoch
topic: string, // the topic the message was published on
data: T, // the arbitrary message data. Must be serializable and a valid argument to 'structuredClone()'
};
```
#### IPC Notes
There are some important things to keep in mind when using Appium's IPC feature:
- IPC is only available once `onIpcInit` has been called, at the end of the `createSession` flow.
It cannot be used statically or in `createSession` hooks
- In sum, IPC is only for use during a session (this is also to prevent drivers/plugins from
accessing or reading data sent on IPC channels in other sessions.)
- The default max size of an IPC message is 1MB. This can be configured by the server-admin by
using the `--max-ipc-data-size` arg (value is a number in bytes).
- If a message exceeds the configured size, any call to `publish` methods will throw, so be
sure to cover this case in your error handling.
- The default max number of IPC topics per session is 1000. This can be configured by the
server-admin using the `--max-ipc-topics` arg.
- If the topic limit is reached, calls to `subscribe` or `publish` on a new topic will throw, so
be sure to cover this case in your error handling.
- Topic names exist within a global namespace per session. So if you don't want to clash with other
drivers or plugins, make sure you prefix your topic names with something unique, akin to vendor
prefixes in the WebDriver standard (for example: `fake-driver:some-topic`). We recommend the
format `<namespace>:<topic>`.
To see examples of IPC in action, check out Appium's own
[FakeDriver](https://github.com/appium/appium/blob/master/packages/fake-driver/lib/driver.ts) and
[FakePlugin](https://github.com/appium/appium/blob/master/packages/fake-plugin/lib/plugin.ts)
extensions, which showcase several ways of using IPC messages.
@@ -518,3 +518,12 @@ async onUnexpectedShutdown(driver, cause) {
}
}
```
### Send messages to the driver or other plugins running on the same session
Even though drivers and plugins don't know anything about each other, it's possible for them to
send messages that other drivers or plugins can listen for, within the context of the same session,
using an IPC channel. Given that the developer instructions for this feature are the same for
drivers and plugins, please head over to the IPC development docs for
drivers at the driver
development guide.
@@ -48,6 +48,8 @@ appium
| `--log-no-colors` | Disable colors in the server log | boolean | `false` |
| `--log-timestamp` | Show timestamps in the server log | boolean | `false` |
| `--long-stacktrace` | Add long stack traces to log entries. Recommended for debugging only. | boolean | `false` |
| `--max-ipc-data-size` | Maximum byte size of IPC message objects. | integer | `1048576` (1MB) |
| `--max-ipc-topics` | Maximum number of IPC topics per session. | integer | `1000` |
| `--no-perms-check` | Skip various permission checks on the server startup | boolean | `false` |
| `--nodeconfig` | JSON configuration for registering Appium as a node with Selenium Grid 3 | object | |
| `--plugin` | Plugin-specific configuration. Keys should correspond to plugin package names | object | |
+40 -9
View File
@@ -11,6 +11,7 @@ import type {
PluginCommand,
Protocol,
RouteMatcher,
IAppiumIpc,
StringRecord,
TimestampedMultiSessionData,
W3CDriverCaps,
@@ -46,6 +47,7 @@ import * as bidiCommands from './bidi-commands';
import * as insecureFeatures from './insecure-features';
import * as inspectorCommands from './inspector-commands';
import type {DriverConfig} from './extension/driver-config';
import {AppiumIpc} from '@appium/base-driver';
const desiredCapabilityConstraints = {
automationName: {
@@ -74,6 +76,11 @@ type SessionHandlerCreateResult = SessionHandlerResult<
type SessionHandlerDeleteResult = SessionHandlerResult<void>;
/** @internal Not part of {@link ExternalDriver}; used only when wiring session IPC. */
type IpcAssignable = {
assignIpc?: (ipc: IAppiumIpc) => Promise<void>;
};
/**
* Umbrella driver: owns the session table, loads platform drivers and plugins, and routes
* commands to the right session driver or plugin chain.
@@ -93,6 +100,8 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
readonly sessionPlugins: Record<string, Plugin[]> = {};
readonly sessionIpcs: Record<string, AppiumIpc> = {};
sessionlessPlugins: Plugin[] = [];
/** Set during server init before listen; tests may assign a mock after construction. */
@@ -361,6 +370,17 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
[...runningDriversData, ...otherPendingDriversData]
)) as [string, DriverCaps<AppiumDriverConstraints> & {webSocketUrl?: string | boolean}];
this.sessions[innerSessionId] = driverInstance;
// create an IPC channel for the driver and all plugins on this session
this.sessionIpcs[innerSessionId] = new AppiumIpc({
maxObjSize: this.args.maxIpcDataSize,
maxTopics: this.args.maxIpcTopics,
log: driverInstance.log,
});
const extDriver = driverInstance as unknown as IpcAssignable;
if (_.isFunction(extDriver.assignIpc)) {
// TODO remove this existence guard as a breaking change in Appium 3
await extDriver.assignIpc(this.sessionIpcs[innerSessionId]);
}
} finally {
_.pull(this.pendingDrivers[InnerDriver.name], driverInstance);
}
@@ -442,8 +462,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
}
this.log.info(`Removing session '${innerSessionId}' from our master session list`);
delete this.sessions[innerSessionId];
delete this.sessionPlugins[innerSessionId];
this.deleteLinkedSessionObjects(innerSessionId);
};
if (_.isFunction(driver.onUnexpectedShutdown)) {
@@ -496,13 +515,6 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
.map(([, value]) => value.driverData);
dstSession = this.sessions[sessionId];
protocol = dstSession.protocol;
this.log.info(`Removing session ${sessionId} from our master session list`);
// regardless of whether the deleteSession completes successfully or not
// make the session unavailable, because who knows what state it might
// be in otherwise
delete this.sessions[sessionId];
delete this.sessionPlugins[sessionId];
this.cleanupBidiSockets(sessionId);
}
// this may not be correct, but if `dstSession` was falsy, the call to `deleteSession()` would
@@ -510,6 +522,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
if (!dstSession) {
throw new Error('Session not found');
}
return {
protocol,
value: await dstSession.deleteSession(sessionId, otherSessionsData),
@@ -521,6 +534,12 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
protocol,
error: e instanceof Error ? e : new Error(msg),
};
} finally {
// regardless of whether the deleteSession completes successfully or not
// make the session unavailable, because who knows what state it might
// be in otherwise
this.log.info(`Removing session ${sessionId} from our master session list`);
this.deleteLinkedSessionObjects(sessionId);
}
}
@@ -767,6 +786,12 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
`${generateDriverLogPrefix(p)} <${generateDriverLogPrefix(this.sessions[newSessionId])}>`
);
}
// we also want to assign the IPC channel for this session to the plugins
const extPlugin = p as unknown as IpcAssignable;
if (_.isFunction(extPlugin.assignIpc)) {
// TODO remove this existence guard as a breaking change in Appium 4
await extPlugin.assignIpc(this.sessionIpcs[newSessionId]);
}
}
this.sessionlessPlugins = [];
}
@@ -902,6 +927,12 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
return _.isFunction(dstSession?.canProxy) && dstSession.canProxy(sessionId);
}
private deleteLinkedSessionObjects(sessionId: string): void {
delete this.sessions[sessionId];
delete this.sessionPlugins[sessionId];
delete this.sessionIpcs[sessionId];
}
}
/**
+2 -2
View File
@@ -1,5 +1,5 @@
import _ from 'lodash';
import B from 'bluebird';
import {promisify} from 'node:util';
import type {ExtensionCore} from '@appium/base-driver';
import {errors} from '@appium/base-driver';
import {BIDI_BASE_PATH, BIDI_EVENT_NAME} from './constants';
@@ -287,7 +287,7 @@ function initBidiSocket(this: AppiumDriver, ws: WebSocket, req: IncomingMessage)
// 1. Make it async-await friendly
// 2. Do some logging if there's a send error
const sendFactory = (socket: WebSocket) => {
const socketSend = B.promisify(socket.send, {context: socket});
const socketSend = promisify(socket.send.bind(socket)) as (data: string | Buffer) => Promise<void>;
return async (data: string | Buffer) => {
try {
await assertIsOpen(socket);
@@ -3,7 +3,6 @@ import _ from 'lodash';
import path from 'node:path';
import os from 'node:os';
import * as semver from 'semver';
import B from 'bluebird';
import {system, fs, npm} from '@appium/support';
import {log as logger} from '../logger';
import {getAppiumModuleRoot, npmPackage} from '../utils';
@@ -90,7 +89,7 @@ export async function showDebugInfo({driverConfig, pluginConfig, appiumHome}: De
const findNpmLocation = async (): Promise<string> =>
await fs.which(system.isWindows() ? 'npm.cmd' : 'npm');
const [npmVersion, npmLocation] = await B.all([
const [npmVersion, npmLocation] = await Promise.all([
...[getNpmVersion, findNpmLocation].map((f) => getSafeResult(f, 'unknown')),
updateBuildInfo() as Promise<unknown>,
]);
+7 -7
View File
@@ -1,4 +1,4 @@
import B from 'bluebird';
import {asyncfilter, asyncmap} from 'asyncbox';
import _ from 'lodash';
import path from 'node:path';
import type {AppiumLogger, ExtensionType, IDoctorCheck} from '@appium/types';
@@ -381,7 +381,7 @@ abstract class ExtensionCliCommand<ExtType extends ExtensionType = ExtensionType
// so that's why it's being removed here.
const extManifest = receiptToManifest(receipt);
const [errors, warnings] = await B.all([
const [errors, warnings] = await Promise.all([
this.config.getProblems(extName, extManifest as any),
this.config.getWarnings(extName, extManifest as any),
]);
@@ -634,7 +634,7 @@ abstract class ExtensionCliCommand<ExtType extends ExtensionType = ExtensionType
}
const isDoctorCheck = (x) =>
['diagnose', 'fix', 'hasAutofix', 'isOptional'].every((method) => _.isFunction(x?.[method]));
const checks: IDoctorCheck[] = _.flatMap((await B.all(loadChecksPromises)).filter(Boolean).map(_.toPairs))
const checks: IDoctorCheck[] = _.flatMap((await Promise.all(loadChecksPromises)).filter(Boolean).map(_.toPairs))
.map(([, value]) => value)
.filter(isDoctorCheck) as IDoctorCheck[];
if (_.isEmpty(checks)) {
@@ -692,7 +692,7 @@ abstract class ExtensionCliCommand<ExtType extends ExtensionType = ExtensionType
if (!scriptName) {
const allScripts = _.toPairs(extScripts as Record<string, string>);
const root = this.config.getInstallPath(installSpec);
const existingScripts = await B.filter(
const existingScripts = await asyncfilter(
allScripts,
async ([, p]) => await fs.exists(path.join(root, p))
);
@@ -747,7 +747,7 @@ abstract class ExtensionCliCommand<ExtType extends ExtensionType = ExtensionType
}
try {
await new B((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
this._runUnbuffered(moduleRoot, scriptPath, extraArgs)
.once('error', (err) => {
// generally this is of the "I can't find the script" variety.
@@ -957,7 +957,7 @@ abstract class ExtensionCliCommand<ExtType extends ExtensionType = ExtensionType
([, data]) => data.installed && data.installType === INSTALL_TYPE_NPM
);
await B.map(
await asyncmap(
extensionsToCheck,
async ([ext, data]) => {
try {
@@ -980,7 +980,7 @@ abstract class ExtensionCliCommand<ExtType extends ExtensionType = ExtensionType
*/
private async _addRepositoryUrlsToListData(listData: ExtensionList): Promise<void> {
await spinWith(this.isJsonOutput, 'Fetching repository information', async () => {
await B.map(
await asyncmap(
_.values(listData),
async (data) => {
const repoUrl = await this._getRepositoryUrl(data);
@@ -2,7 +2,6 @@ import type {ExtensionType} from '@appium/types';
import type {ExtClass, ExtManifest, ExtName, ExtRecord, InstallType} from 'appium/types';
import type {SchemaObject} from 'ajv';
import {util, fs, system} from '@appium/support';
import B from 'bluebird';
import _ from 'lodash';
import path from 'node:path';
import {pathToFileURL} from 'node:url';
@@ -153,7 +152,7 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
* @param extManifest - Manifest entry for that extension
*/
async getWarnings(extName: string, extManifest: ExtManifest<ExtType>): Promise<string[]> {
const [genericConfigWarnings, configWarnings] = await B.all([
const [genericConfigWarnings, configWarnings] = await Promise.all([
this.getGenericConfigWarnings(extManifest, extName),
this.getConfigWarnings(extManifest, extName),
]);
@@ -359,7 +358,7 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
const warningMap = new Map<string, string[]>();
for (const [extName, extManifest] of _.toPairs(exts)) {
const [errors, warnings] = await B.all([
const [errors, warnings] = await Promise.all([
this.getProblems(extName, extManifest),
this.getWarnings(extName, extManifest),
]);
+22 -30
View File
@@ -1,5 +1,5 @@
import _ from 'lodash';
import B from 'bluebird';
import {asyncmap} from 'asyncbox';
import type {DriverClass, ExtensionType, PluginClass} from '@appium/types';
import type {ExtClass} from 'appium/types';
import {USE_ALL_PLUGINS} from '../constants';
@@ -32,7 +32,7 @@ export async function loadExtensions(appiumHome: string): Promise<ExtensionConfi
const driverConfig = DriverConfig.getInstance(manifest) ?? DriverConfig.create(manifest);
const pluginConfig = PluginConfig.getInstance(manifest) ?? PluginConfig.create(manifest);
await B.all([driverConfig.validate(), pluginConfig.validate()]);
await Promise.all([driverConfig.validate(), pluginConfig.validate()]);
return {driverConfig, pluginConfig};
}
@@ -110,34 +110,26 @@ async function importExtensions(
extNames: string[],
asyncImportChunkSize: number
): Promise<Array<[ExtClass<ExtensionType>, string]>> {
const allPromises: Array<B<ExtClass<ExtensionType> | undefined>> = [];
const activePromisesChunk: Array<B<ExtClass<ExtensionType> | undefined>> = [];
for (const extName of extNames) {
_.remove(activePromisesChunk, (p) => p.isFulfilled());
if (activePromisesChunk.length >= asyncImportChunkSize) {
await B.any(activePromisesChunk);
}
const promise = B.resolve(
(async () => {
log.info(`Attempting to load ${extType} ${extName}...`);
const timer = new timing.Timer().start();
try {
const extClass = await config.requireAsync(extName as never);
log.debug(`${extClass.name} has been successfully loaded in ${timer.getDuration().asSeconds.toFixed(3)}s`);
return extClass as ExtClass<ExtensionType>;
} catch (err: any) {
log.error(
`Could not load ${extType} '${extName}', so it will not be available. Error ` +
`in loading the ${extType} was: ${err.message}`
);
log.debug(err.stack);
}
})()
);
activePromisesChunk.push(promise);
allPromises.push(promise);
}
return _.zip(await B.all(allPromises), extNames).filter(([extClass]) => Boolean(extClass)) as Array<
const extClasses = await asyncmap(
extNames,
async (extName) => {
log.info(`Attempting to load ${extType} ${extName}...`);
const timer = new timing.Timer().start();
try {
const extClass = await config.requireAsync(extName as never);
log.debug(`${extClass.name} has been successfully loaded in ${timer.getDuration().asSeconds.toFixed(3)}s`);
return extClass as ExtClass<ExtensionType>;
} catch (err: any) {
log.error(
`Could not load ${extType} '${extName}', so it will not be available. Error ` +
`in loading the ${extType} was: ${err.message}`
);
log.debug(err.stack);
}
},
{concurrency: asyncImportChunkSize}
);
return _.zip(extClasses, extNames).filter(([extClass]) => Boolean(extClass)) as Array<
[ExtClass<ExtensionType>, string]
>;
}
+1 -2
View File
@@ -1,4 +1,3 @@
import B from 'bluebird';
import {env, fs} from '@appium/support';
import _ from 'lodash';
import path from 'node:path';
@@ -224,7 +223,7 @@ export class Manifest {
queue.push(onMatch(filepath));
}
await B.all(queue);
await Promise.all(queue);
return didChange;
}
+14 -14
View File
@@ -1,6 +1,6 @@
{
"name": "appium",
"version": "3.4.0",
"version": "3.5.0",
"description": "Automation for Apps.",
"keywords": [
"automation",
@@ -60,31 +60,31 @@
"test:unit": "mocha \"./test/unit/**/*.spec.ts\""
},
"dependencies": {
"@appium/base-driver": "10.5.0",
"@appium/base-plugin": "3.2.2",
"@appium/docutils": "2.4.0",
"@appium/logger": "2.0.7",
"@appium/schema": "1.1.1",
"@appium/support": "7.2.0",
"@appium/types": "1.4.0",
"@appium/base-driver": "10.6.0",
"@appium/base-plugin": "3.3.0",
"@appium/docutils": "2.4.3",
"@appium/logger": "2.0.8",
"@appium/schema": "1.2.0",
"@appium/support": "7.2.3",
"@appium/types": "1.5.0",
"@sidvind/better-ajv-errors": "5.0.0",
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"argparse": "2.0.1",
"axios": "1.16.0",
"bluebird": "3.7.2",
"asyncbox": "6.3.0",
"axios": "1.16.1",
"lilconfig": "3.1.3",
"lodash": "4.18.1",
"lru-cache": "11.3.5",
"lru-cache": "11.5.0",
"ora": "5.4.1",
"package-changed": "3.0.0",
"resolve-from": "5.0.0",
"semver": "7.7.4",
"semver": "7.8.1",
"teen_process": "4.1.3",
"type-fest": "5.6.0",
"winston": "3.19.0",
"ws": "8.20.0",
"yaml": "2.8.4"
"ws": "8.21.0",
"yaml": "2.9.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
@@ -1,5 +1,5 @@
{
"devDependencies": {
"webdriverio": "9.27.1"
"webdriverio": "9.27.2"
}
}
@@ -21,14 +21,9 @@
* `npm install -g appium --drivers=uiautomator2,xcuitest --plugins=images`
*/
const B = require('bluebird');
const path = require('node:path');
const {realpath} = require('node:fs/promises');
B.config({
cancellation: true,
});
/** @type {typeof import('../lib/cli/extension').runExtensionCommand} */
let runExtensionCommand;
/** @type {typeof import('../lib/constants').DRIVER_TYPE} */
@@ -443,4 +443,45 @@ describe('FakePlugin w/ FakeDriver via HTTP', function () {
});
});
});
describe('IPC Support', function () {
let driver: Browser;
// this 'after' block needs to come before 'serverSetup' so that the delete session happens
// before the server shutdown
after(async function () {
if (driver) {
await driver.deleteSession();
}
});
serverSetup({});
before(async function () {
const caps = {...wdOpts.capabilities, webSocketUrl: true, 'appium:runClock': true};
driver = await wdio({...wdOpts, capabilities: caps} as any);
});
it('should allow driver to publish to plugin', async function () {
let running = await driver.executeScript('fake: getFakeDriverClockStatus', []);
expect(running).to.be.true;
await driver.executeScript('fake: stopClock', []);
running = await driver.executeScript('fake: getFakeDriverClockStatus', []);
expect(running).to.be.false;
});
it('should allow plugin to publish to driver', async function () {
let lastMath = await driver.executeScript('fake: getLastPluginMath', []);
expect(lastMath).to.eql(null);
const {result} = await (driver as any).send({
method: 'appium:fake.doSomeMath2',
params: {num1: 2, num2: 3},
});
expect(result).to.eql(6);
lastMath = await driver.executeScript('fake: getLastPluginMath', []);
expect(lastMath.result).to.eql(6);
expect(lastMath.pluginName).to.have.string('FakePlugin');
});
});
});
+2
View File
@@ -15,6 +15,8 @@ export default {
logNoColors: false,
logTimestamp: false,
longStacktrace: false,
maxIpcDataSize: 1048576,
maxIpcTopics: 1000,
noPermsCheck: false,
pluginsImportChunkSize: 7,
port: 4723,
+29
View File
@@ -3,6 +3,35 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [10.6.0](https://github.com/appium/appium/compare/@appium/base-driver@10.5.2...@appium/base-driver@10.6.0) (2026-05-31)
### Features
* add IPC channels per session for drivers and plugins to communicate ([#22211](https://github.com/appium/appium/issues/22211)) ([97c879c](https://github.com/appium/appium/commit/97c879cdd02e6fcccaaea45a8d1a7088bed4ae66)), closes [#22206](https://github.com/appium/appium/issues/22206)
* **base-driver:** Ditch bluebird ([#22293](https://github.com/appium/appium/issues/22293)) ([c9b7eac](https://github.com/appium/appium/commit/c9b7eac3653027245cdcde1a5880c0ca2c1c9ed1))
* **base-driver:** Ditch lodash ([#22324](https://github.com/appium/appium/issues/22324)) ([cf79b32](https://github.com/appium/appium/commit/cf79b3283a85ad0bc38359c345ac33669867e853))
* Limit the maximum count of topics per session and make it configurable ([#22279](https://github.com/appium/appium/issues/22279)) ([0264775](https://github.com/appium/appium/commit/0264775ecb8ecaca9dac2eae47784da02a653255))
* make assignIpc internal ([#22278](https://github.com/appium/appium/issues/22278)) ([25d922b](https://github.com/appium/appium/commit/25d922b7d3062c306c1631d6f5c227600ddd226a))
## [10.5.2](https://github.com/appium/appium/compare/@appium/base-driver@10.5.1...@appium/base-driver@10.5.2) (2026-05-07)
**Note:** Version bump only for package @appium/base-driver
## [10.5.1](https://github.com/appium/appium/compare/@appium/base-driver@10.5.0...@appium/base-driver@10.5.1) (2026-05-06)
**Note:** Version bump only for package @appium/base-driver
## [10.5.0](https://github.com/appium/appium/compare/@appium/base-driver@10.4.0...@appium/base-driver@10.5.0) (2026-05-06)
@@ -9,9 +9,9 @@ import type {
KeyAsString,
MergeExclusive,
} from 'type-fest';
import _ from 'lodash';
import {validator} from './validation';
import {util} from '@appium/support';
import {omit, pickBy} from '../utils';
import {log} from './logger';
import {errors} from '../protocol/errors';
@@ -50,7 +50,7 @@ export function mergeCaps<
for (const [name, value] of Object.entries(secondary)) {
// Overwriting is not allowed. Primary and secondary must have different properties (w3c rule 4.4)
if (!_.isUndefined(primary[name])) {
if (primary[name] !== undefined) {
throw new errors.InvalidArgumentError(
`property '${name}' should not exist on both primary (${JSON.stringify(
primary
@@ -73,32 +73,31 @@ export function validateCaps<C extends Constraints>(
): Capabilities<C> {
const {skipPresenceConstraint} = opts;
if (!_.isPlainObject(caps)) {
if (!util.isPlainObject(caps)) {
throw new errors.InvalidArgumentError(`must be a JSON object`);
}
// Remove the 'presence' constraint if we're not checking for it
constraints = (
_.mapValues(
constraints,
skipPresenceConstraint
? /** @param {Constraint} constraint */
(constraint) => _.omit(constraint, 'presence')
: /** @param {Constraint} constraint */
(constraint) => {
if (constraint.presence === true) {
return {..._.omit(constraint, 'presence'), presence: {allowEmpty: false}};
}
return constraint;
}
)
constraints = Object.fromEntries(
Object.entries(constraints ?? {}).map(([key, constraint]) => {
if (skipPresenceConstraint) {
return [key, omit(constraint as Record<string, unknown>, 'presence')];
}
if ((constraint as {presence?: unknown}).presence === true) {
return [key, {...omit(constraint as Record<string, unknown>, 'presence'), presence: {allowEmpty: false}}];
}
return [key, constraint];
})
) as C;
const validationErrors = validator.validate(_.pickBy(caps, util.hasValue), constraints);
const validationErrors = validator.validate(
pickBy(caps, (value) => util.hasValue(value)),
constraints
);
if (validationErrors) {
const message: string[] = [];
for (const [attribute, reasons] of _.toPairs(validationErrors)) {
for (const [attribute, reasons] of Object.entries(validationErrors)) {
for (const reason of (reasons as string[])) {
message.push(`'${attribute}' ${reason}`);
}
@@ -151,12 +150,14 @@ export function isStandardCap(cap: string): boolean {
export function stripAppiumPrefixes<C extends Constraints>(caps: NSCapabilities<C>): Capabilities<C> {
// split into prefixed and non-prefixed.
// non-prefixed should be standard caps at this point
const [prefixedCaps, nonPrefixedCaps] = _.partition(_.keys(caps), (cap) =>
String(cap).startsWith(APPIUM_VENDOR_PREFIX)
);
const capKeys = Object.keys(caps);
const prefixedCaps = capKeys.filter((cap) => String(cap).startsWith(APPIUM_VENDOR_PREFIX));
const nonPrefixedCaps = capKeys.filter((cap) => !String(cap).startsWith(APPIUM_VENDOR_PREFIX));
// initialize this with the k/v pairs of the non-prefixed caps
const strippedCaps = (_.pick(caps, nonPrefixedCaps)) as Capabilities<C>;
const strippedCaps: Record<string, unknown> = Object.fromEntries(
nonPrefixedCaps.map((cap) => [cap, caps[cap as keyof typeof caps]])
) as Capabilities<C>;
const badPrefixedCaps: string[] = [];
// Strip out the 'appium:' prefix
@@ -166,16 +167,16 @@ export function stripAppiumPrefixes<C extends Constraints>(caps: NSCapabilities<
// If it's standard capability that was prefixed, add it to an array of incorrectly prefixed capabilities
if (isStandardCap(strippedCapName)) {
badPrefixedCaps.push(strippedCapName);
if (_.isNil(strippedCaps[strippedCapName])) {
strippedCaps[strippedCapName] = caps[prefixedCap];
if (strippedCaps[strippedCapName] == null) {
strippedCaps[strippedCapName] = caps[prefixedCap as keyof typeof caps];
} else {
log.warn(
`Ignoring capability '${prefixedCap}=${caps[prefixedCap]}' and ` +
`Ignoring capability '${prefixedCap}=${caps[prefixedCap as keyof typeof caps]}' and ` +
`using capability '${strippedCapName}=${strippedCaps[strippedCapName]}'`
);
}
} else {
strippedCaps[strippedCapName] = caps[prefixedCap];
strippedCaps[strippedCapName] = caps[prefixedCap as keyof typeof caps];
}
}
@@ -187,7 +188,7 @@ export function stripAppiumPrefixes<C extends Constraints>(caps: NSCapabilities<
)} are standard capabilities and do not require "appium:" prefix`
);
}
return strippedCaps;
return strippedCaps as Capabilities<C>;
}
/**
@@ -199,16 +200,15 @@ export function findNonPrefixedCaps<C extends Constraints>(
firstMatch = []
}: W3CCapabilities<C>
): string[] {
return _.chain([alwaysMatch, ...firstMatch])
.reduce(
return util.uniq(
[alwaysMatch, ...firstMatch].reduce<string[]>(
(unprefixedCaps, caps) => [
...unprefixedCaps,
...Object.keys(caps).filter((cap) => !cap.includes(':') && !isStandardCap(cap)),
],
[]
)
.uniq()
.value();
);
}
/**
@@ -221,7 +221,7 @@ export function parseCaps<C extends Constraints>(
shouldValidateCaps: boolean | undefined = true
): ParsedCaps<C> {
// If capabilities request is not an object, return error (#1.1)
if (!_.isPlainObject(caps)) {
if (!util.isPlainObject(caps)) {
throw new errors.InvalidArgumentError(
'The capabilities argument was not valid for the following reason(s): "capabilities" must be a JSON object.'
);
@@ -235,7 +235,7 @@ export function parseCaps<C extends Constraints>(
} = caps;
// Reject 'firstMatch' argument if it's not an array (#3.2)
if (!_.isArray(allFirstMatchCaps)) {
if (!Array.isArray(allFirstMatchCaps)) {
throw new errors.InvalidArgumentError(
'The capabilities.firstMatch argument was not valid for the following reason(s): "capabilities.firstMatch" must be a JSON array or undefined'
);
@@ -253,7 +253,7 @@ export function parseCaps<C extends Constraints>(
// Check for non-prefixed, non-standard capabilities and log warnings if they are found
const nonPrefixedCaps = findNonPrefixedCaps(caps);
if (!_.isEmpty(nonPrefixedCaps)) {
if (!util.isEmpty(nonPrefixedCaps)) {
throw new errors.InvalidArgumentError(
`All non-standard capabilities should have a vendor prefix. The following capabilities did not have one: ${nonPrefixedCaps}`
);
@@ -271,22 +271,24 @@ export function parseCaps<C extends Constraints>(
}
// Remove the 'presence' constraint for any keys that are already present in 'requiredCaps'
// since we know that this constraint has already passed
const filteredConstraints = _.omitBy(constraints, (_, key) => key in strippedRequiredCaps) as C;
const filteredConstraints = Object.fromEntries(
Object.entries(constraints ?? {}).filter(([key]) => !(key in strippedRequiredCaps))
) as C;
// Validate all of the first match capabilities and return an array with only the valid caps (see spec #5)
const validationErrors: string[] = [];
const validatedFirstMatchCaps = _.compact(
strippedAllFirstMatchCaps.map((firstMatchCaps) => {
const validatedFirstMatchCaps = strippedAllFirstMatchCaps
.map((firstMatchCaps) => {
try {
// Validate firstMatch caps
return shouldValidateCaps
? validateCaps(firstMatchCaps, filteredConstraints)
: firstMatchCaps;
} catch (e) {
validationErrors.push(e.message);
validationErrors.push((e as Error).message);
}
})
) as Capabilities<C>[];
.filter(Boolean) as Capabilities<C>[];
/**
* Try to merge requiredCaps with first match capabilities, break once it finds its first match
@@ -300,8 +302,8 @@ export function parseCaps<C extends Constraints>(
break;
}
} catch (err) {
log.warn(err.message);
validationErrors.push(err.message);
log.warn((err as Error).message);
validationErrors.push((err as Error).message);
}
}
@@ -330,7 +332,7 @@ export function processCapabilities<
// If we found an error throw an exception
if (!util.hasValue(matchedCaps)) {
if (_.isArray(w3cCaps.firstMatch) && w3cCaps.firstMatch.length > 1) {
if (Array.isArray(w3cCaps.firstMatch) && w3cCaps.firstMatch.length > 1) {
// If there was more than one 'firstMatch' cap, indicate that we couldn't find a matching capabilities set and show all the errors
throw new errors.InvalidArgumentError(
`Could not find matching capabilities from ${JSON.stringify(
@@ -356,12 +358,12 @@ export function promoteAppiumOptionsForObject<C extends Constraints>(obj: NSCapa
return obj;
}
if (!_.isPlainObject(appiumOptions)) {
if (!util.isPlainObject(appiumOptions)) {
throw new errors.SessionNotCreatedError(
`The ${PREFIXED_APPIUM_OPTS_CAP} capability must be an object`
);
}
if (_.isEmpty(appiumOptions)) {
if (util.isEmpty(appiumOptions)) {
return obj;
}
@@ -374,7 +376,7 @@ export function promoteAppiumOptionsForObject<C extends Constraints>(obj: NSCapa
*/
const shouldAddVendorPrefix = (capName: string) => !capName.startsWith(APPIUM_VENDOR_PREFIX);
const verifyIfAcceptable = (capName: string) => {
if (!_.isString(capName)) {
if (typeof capName !== 'string') {
throw new errors.SessionNotCreatedError(
`Capability names in ${PREFIXED_APPIUM_OPTS_CAP} must be strings. '${capName}' is unexpected`
);
@@ -386,20 +388,23 @@ export function promoteAppiumOptionsForObject<C extends Constraints>(obj: NSCapa
}
return capName;
};
const preprocessedOptions = _(appiumOptions)
.mapKeys((value, key: string) => verifyIfAcceptable(key))
.mapKeys((value, key: string) => (shouldAddVendorPrefix(key) ? `${APPIUM_VENDOR_PREFIX}${key}` : key))
.value();
const preprocessedOptions: Record<string, unknown> = {};
for (const [key, value] of Object.entries(appiumOptions as Record<string, unknown>)) {
const verifiedKey = verifyIfAcceptable(key);
const finalKey = shouldAddVendorPrefix(verifiedKey) ? `${APPIUM_VENDOR_PREFIX}${verifiedKey}` : verifiedKey;
preprocessedOptions[finalKey] = value;
}
// warn if we are going to overwrite any keys on the base caps object
const overwrittenKeys = _.intersection(Object.keys(obj), Object.keys(preprocessedOptions));
const overwrittenKeys = Object.keys(obj).filter((key) => key in preprocessedOptions);
if (overwrittenKeys.length > 0) {
log.warn(
`Found capabilities inside ${PREFIXED_APPIUM_OPTS_CAP} that will overwrite ` +
`capabilities at the top level: ${JSON.stringify(overwrittenKeys)}`
);
}
return _.cloneDeep({
..._.omit(obj, PREFIXED_APPIUM_OPTS_CAP) as NSCapabilities<C>,
const restObj = omit(obj, PREFIXED_APPIUM_OPTS_CAP) as NSCapabilities<C>;
return structuredClone({
...restObj,
...preprocessedOptions,
});
}
@@ -411,12 +416,12 @@ export function promoteAppiumOptionsForObject<C extends Constraints>(obj: NSCapa
export function promoteAppiumOptions<C extends Constraints>(originalCaps: W3CCapabilities<C>): W3CCapabilities<C> {
const result = {} as W3CCapabilities<C>;
const {alwaysMatch, firstMatch} = originalCaps;
if (_.isPlainObject(alwaysMatch)) {
if (util.isPlainObject(alwaysMatch)) {
result.alwaysMatch = promoteAppiumOptionsForObject(alwaysMatch);
} else if ('alwaysMatch' in originalCaps) {
result.alwaysMatch = alwaysMatch;
}
if (_.isArray(firstMatch)) {
if (Array.isArray(firstMatch)) {
result.firstMatch = firstMatch.map(promoteAppiumOptionsForObject);
} else if ('firstMatch' in originalCaps) {
result.firstMatch = firstMatch;
@@ -1,7 +1,7 @@
import {util} from '@appium/support';
import type {Constraints, DriverStatus, IBidiCommands} from '@appium/types';
import type {BaseDriver} from '../driver';
import {mixin} from './mixin';
import _ from 'lodash';
declare module '../driver' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -36,15 +36,15 @@ const BidiCommands: IBidiCommands = {
async bidiStatus<C extends Constraints>(this: BaseDriver<C>): Promise<DriverStatus> {
const result = await this.getStatus();
if (!_.has(result, 'ready')) {
//@ts-ignore This is OK
result.ready = true;
}
if (!_.has(result, 'message')) {
//@ts-ignore This is OK
result.message = `${this.constructor.name} is ready to accept commands`;
}
return result as DriverStatus;
const base: Record<string, unknown> = util.isPlainObject(result) ? {...result} : {};
return {
...base,
ready: 'ready' in base ? (base.ready as boolean) : true,
message:
'message' in base
? (base.message as string)
: `${this.constructor.name} is ready to accept commands`,
};
}
};
@@ -1,5 +1,5 @@
import type {Constraints, IEventCommands} from '@appium/types';
import _ from 'lodash';
import type {Constraints, EventHistory, IEventCommands} from '@appium/types';
import {util} from '@appium/support';
import type {BaseDriver} from '../driver';
import {mixin} from './mixin';
@@ -16,7 +16,7 @@ const EventCommands: IEventCommands = {
* separation
* @param event - the event name
*/
async logCustomEvent<C extends Constraints>(this: BaseDriver<C>, vendor: string, event: string) {
async logCustomEvent<C extends Constraints>(this: BaseDriver<C>, vendor: string, event: string): Promise<void> {
this.logEvent(`${vendor}:${event}`);
},
@@ -26,23 +26,24 @@ const EventCommands: IEventCommands = {
* It returns all events if the type is not provided or empty string/array.
* @returns the event history log object
*/
async getLogEvents<C extends Constraints>(this: BaseDriver<C>, type: string | string[]) {
if (_.isEmpty(type)) {
async getLogEvents<C extends Constraints>(this: BaseDriver<C>, type: string | string[]): Promise<
Partial<EventHistory>
> {
if (util.isEmpty(type)) {
return this.eventHistory;
}
const typeList = _.castArray(type);
const typeList = Array.isArray(type) ? type : [type];
return _.reduce(
this.eventHistory,
(acc, eventTimes, eventType) => {
return Object.entries(this.eventHistory).reduce<Partial<EventHistory>>(
(acc, [eventType, eventTimes]) => {
if (typeList.includes(eventType)) {
acc[eventType] = eventTimes;
}
return acc;
},
{}
);
) as Record<string, number>;
},
};
@@ -1,4 +1,4 @@
import _ from 'lodash';
import {util} from '@appium/support';
import {errors, validateExecuteMethodParams} from '../../protocol';
import type {
Constraints,
@@ -26,8 +26,8 @@ const ExecuteCommands: IExecuteCommands = {
const Driver = this.constructor as DriverClass<Driver<C>>;
const commandMetadata = {...Driver.executeMethodMap?.[script]};
if (!commandMetadata.command) {
const availableScripts = _.keys(Driver.executeMethodMap);
if (_.isEmpty(availableScripts)) {
const availableScripts = Object.keys(Driver.executeMethodMap ?? {});
if (util.isEmpty(availableScripts)) {
throw new errors.UnsupportedOperationError(
`Unsupported execute method '${script}'. ` +
`Make sure the installed ${Driver.name} is up-to-date. ` +
@@ -1,5 +1,4 @@
import type {Constraints, Driver, ILogCommands} from '@appium/types';
import _ from 'lodash';
import type {BaseDriver} from '../driver';
import {mixin} from './mixin';
@@ -20,7 +19,9 @@ const LogCommands: ILogCommands = {
this.log.debug(`Retrieving '${String(logType)}' logs`);
if (!(logType in this.supportedLogTypes)) {
const logsTypesWithDescriptions = _.mapValues(this.supportedLogTypes, 'description');
const logsTypesWithDescriptions = Object.fromEntries(
Object.entries(this.supportedLogTypes).map(([key, value]) => [key, value.description])
);
throw new Error(
`Unsupported log type '${String(logType)}'. ` +
`Supported types: ${JSON.stringify(logsTypesWithDescriptions)}`
@@ -1,5 +1,4 @@
import {waitForCondition} from 'asyncbox';
import _ from 'lodash';
import {util} from '@appium/support';
import {errors} from '../../protocol';
import type {BaseDriver} from '../driver';
@@ -15,7 +14,7 @@ const MIN_TIMEOUT = 0;
const TimeoutCommands: ITimeoutCommands = {
async timeouts<C extends Constraints>(this: BaseDriver<C>, type, ms, script, pageLoad, implicit) {
if (type && _.isString(type) && util.hasValue(ms)) {
if (type && typeof type === 'string' && util.hasValue(ms)) {
// legacy stuff with some Appium-specific additions
this.log.debug(`Timeout arguments: ${JSON.stringify({type, ms})}}`);
switch (type) {
@@ -33,7 +32,7 @@ const TimeoutCommands: ITimeoutCommands = {
}
this.log.debug(`W3C timeout argument: ${JSON.stringify({script, pageLoad, implicit})}}`);
if ([script, pageLoad, implicit].every(_.isNil)) {
if ([script, pageLoad, implicit].every((value) => value == null)) {
throw new errors.InvalidArgumentError('W3C protocol expects any of script, pageLoad or implicit to be set');
}
if (util.hasValue(script)) {
@@ -82,7 +81,7 @@ const TimeoutCommands: ITimeoutCommands = {
if (this.managedDrivers?.length) {
this.log.debug('Setting implicit wait on managed drivers');
for (const driver of this.managedDrivers) {
if (_.isFunction(driver.setImplicitWait)) {
if (typeof driver.setImplicitWait === 'function') {
driver.setImplicitWait(ms);
}
}
@@ -95,7 +94,7 @@ const TimeoutCommands: ITimeoutCommands = {
if (this.managedDrivers?.length) {
this.log.debug('Setting new command timeout on managed drivers');
for (const driver of this.managedDrivers) {
if (_.isFunction(driver.setNewCommandTimeout)) {
if (typeof driver.setNewCommandTimeout === 'function') {
driver.setNewCommandTimeout(ms);
}
}
@@ -122,7 +121,7 @@ const TimeoutCommands: ITimeoutCommands = {
parseTimeoutArgument<C extends Constraints>(this: BaseDriver<C>, ms: number | string) {
const duration = parseInt(String(ms), 10);
if (_.isNaN(duration) || duration < MIN_TIMEOUT) {
if (Number.isNaN(duration) || duration < MIN_TIMEOUT) {
throw new errors.UnknownError(`Invalid timeout value '${ms}'`);
}
return duration;
+12 -12
View File
@@ -12,7 +12,7 @@ import type {
StringRecord,
} from '@appium/types';
import AsyncLock from 'async-lock';
import _ from 'lodash';
import {util} from '@appium/support';
import os from 'node:os';
import {
DEFAULT_BASE_PATH,
@@ -116,7 +116,7 @@ export class DriverCore<const C extends Constraints, Settings extends StringReco
this.shouldValidateCaps = shouldValidateCaps;
// keeping track of initial opts
this.initialOpts = _.cloneDeep(opts);
this.initialOpts = structuredClone(opts);
this.sessionId = null;
this.helpers = helpers;
@@ -166,7 +166,7 @@ export class DriverCore<const C extends Constraints, Settings extends StringReco
* inadvertently change data outside of logEvent
*/
get eventHistory() {
return _.cloneDeep(this._eventHistory);
return structuredClone(this._eventHistory);
}
/**
@@ -263,7 +263,7 @@ export class DriverCore<const C extends Constraints, Settings extends StringReco
isFeatureEnabled(name: string): boolean {
// automationName comparison is case-insensitive,
// while feature name is case-sensitive
const currentAutomationName = _.toLower(this.opts.automationName);
const currentAutomationName = String(this.opts.automationName).toLowerCase();
const parseFullName = (fullName: string) => {
const separatorPos = fullName.indexOf(FEATURE_NAME_SEPARATOR);
@@ -279,7 +279,7 @@ export class DriverCore<const C extends Constraints, Settings extends StringReco
);
}
return [
_.toLower(fullName.substring(0, separatorPos)),
fullName.substring(0, separatorPos).toLowerCase(),
fullName.substring(separatorPos + 1)
];
};
@@ -288,12 +288,12 @@ export class DriverCore<const C extends Constraints, Settings extends StringReco
[currentAutomationName, ALL_DRIVERS_MATCH].includes(automationName) && featureName === name;
// if we have explicitly denied this feature, return false immediately
if (!_.isEmpty(this.denyInsecure) && parseFullNames(this.denyInsecure).some(matches)) {
if (!util.isEmpty(this.denyInsecure) && parseFullNames(this.denyInsecure).some(matches)) {
return false;
}
// if we specifically have allowed the feature, return true
if (!_.isEmpty(this.allowInsecure) && parseFullNames(this.allowInsecure).some(matches)) {
if (!util.isEmpty(this.allowInsecure) && parseFullNames(this.allowInsecure).some(matches)) {
return true;
}
@@ -332,7 +332,7 @@ export class DriverCore<const C extends Constraints, Settings extends StringReco
validStrategies = validStrategies.concat(this.webLocatorStrategies);
}
if (!_.includes(validStrategies, strategy)) {
if (!validStrategies.includes(strategy)) {
throw new errors.InvalidSelectorError(
`Locator Strategy '${strategy}' is not supported for this session`,
);
@@ -370,17 +370,17 @@ export class DriverCore<const C extends Constraints, Settings extends StringReco
// eslint-disable-next-line @typescript-eslint/no-unused-vars
proxyRouteIsAvoided(sessionId: string, method: HTTPMethod, url: string, body?: any): boolean {
for (const avoidSchema of this.getProxyAvoidList(sessionId)) {
if (!_.isArray(avoidSchema) || avoidSchema.length !== 2) {
if (!Array.isArray(avoidSchema) || avoidSchema.length !== 2) {
throw new Error('Proxy avoidance must be a list of pairs');
}
const [avoidMethod, avoidPathRegex] = avoidSchema;
if (!_.includes(['GET', 'POST', 'DELETE'], avoidMethod)) {
if (!['GET', 'POST', 'DELETE'].includes(avoidMethod)) {
throw new Error(`Unrecognized proxy avoidance method '${avoidMethod}'`);
}
if (!_.isRegExp(avoidPathRegex)) {
if (!(avoidPathRegex instanceof RegExp)) {
throw new Error('Proxy avoidance path must be a regular expression');
}
const normalizedUrl = url.replace(new RegExp(`^${_.escapeRegExp(this.basePath)}`), '');
const normalizedUrl = url.replace(new RegExp(`^${util.escapeRegExp(this.basePath)}`), '');
if (avoidMethod === method && avoidPathRegex.test(normalizedUrl)) {
return true;
}
@@ -1,6 +1,5 @@
import _ from 'lodash';
import {util, node} from '@appium/support';
import {log} from './logger';
import {node, util} from '@appium/support';
import {errors} from '../protocol/errors';
import type {StringRecord, IDeviceSettings, SettingsUpdateListener} from '@appium/types';
@@ -36,7 +35,7 @@ export class DeviceSettings<T extends StringRecord = StringRecord> implements ID
* @param newSettings - New settings to merge (must be plain object; total size remains bounded).
*/
async update(newSettings: T): Promise<void> {
if (!_.isPlainObject(newSettings)) {
if (!util.isPlainObject(newSettings)) {
throw new errors.InvalidArgumentError(
`Settings update should be called with valid JSON. Got ` +
`${JSON.stringify(newSettings)} instead`
@@ -51,7 +50,7 @@ export class DeviceSettings<T extends StringRecord = StringRecord> implements ID
}
for (const prop in newSettings) {
if (!_.isUndefined(this._settings[prop])) {
if (this._settings[prop] !== undefined) {
if (this._settings[prop] === newSettings[prop]) {
log.debug(`The value of '${prop}' setting did not change. Skipping the update for it`);
continue;
+15 -13
View File
@@ -17,8 +17,6 @@ import {
type SingularSessionData,
type SessionCapabilities,
} from '@appium/types';
import B from 'bluebird';
import _ from 'lodash';
import {fixCaps, isW3cCaps} from '../helpers/capabilities';
import {getLevenshteinSuggestion} from '../helpers/levenshtein-match';
import {calcSignature} from '../helpers/session';
@@ -27,6 +25,7 @@ import {processCapabilities, validateCaps} from './capabilities';
import {DriverCore} from './core';
import * as helpers from './helpers';
import {resolveExecuteExtensionName} from '../helpers/extension-command-name';
import {mergePlainObjects} from '../utils';
const EVENT_SESSION_INIT = 'newSessionRequested';
const EVENT_SESSION_START = 'newSessionStarted';
@@ -69,7 +68,9 @@ export class BaseDriver<
* @see {@link https://github.com/appium/appium/issues/new}
*/
protected get _desiredCapConstraints(): Readonly<BaseDriverCapConstraints & C> {
return Object.freeze(_.merge({}, BASE_DESIRED_CAP_CONSTRAINTS, this.desiredCapConstraints));
return Object.freeze(
mergePlainObjects({}, BASE_DESIRED_CAP_CONSTRAINTS, this.desiredCapConstraints) as BaseDriverCapConstraints & C
);
}
/**
@@ -113,11 +114,11 @@ export class BaseDriver<
unexpectedShutdownRejecter?.(e);
};
try {
return await B.race([
return await Promise.race([
this[cmd](...args),
// This promise is needed to monitor if the session has been
// shut down unexpectedly while the command was running
new B((resolve, reject) => {
new Promise((resolve, reject) => {
unexpectedShutdownResolver = resolve;
unexpectedShutdownRejecter = reject;
this.eventEmitter.once(ON_UNEXPECTED_SHUTDOWN_EVENT, onUnexpectedShutdown);
@@ -180,7 +181,7 @@ export class BaseDriver<
clarifyCommandName(cmd: string, args: string[]): string {
if (cmd === 'execute') {
const firstArg = args?.[0];
if (_.isString(firstArg) && firstArg.trim().length > 0) {
if (typeof firstArg === 'string' && firstArg.trim().length > 0) {
return resolveExecuteExtensionName.call(this, firstArg);
}
}
@@ -257,7 +258,7 @@ export class BaseDriver<
await this.createSession(this.originalCaps);
} finally {
// always restore state.
for (const [key, value] of _.toPairs(currentConfig)) {
for (const [key, value] of Object.entries(currentConfig)) {
this[key] = value;
}
}
@@ -286,7 +287,7 @@ export class BaseDriver<
this.log.debug();
const originalCaps = _.cloneDeep(
const originalCaps = structuredClone(
[w3cCapabilities, w3cCapabilities1, w3cCapabilities2].find(isW3cCaps),
);
if (!originalCaps) {
@@ -321,7 +322,7 @@ export class BaseDriver<
this.sessionCreationTimestampMs = Date.now();
this.caps = caps;
// merge caps onto opts so we don't need to worry about what's where
this.opts = {..._.cloneDeep(this.initialOpts), ...this.caps};
this.opts = {...structuredClone(this.initialOpts), ...this.caps};
// deal with resets
// some people like to do weird things by setting noReset and fullReset
@@ -347,7 +348,7 @@ export class BaseDriver<
delete this.opts.app;
}
if (!_.isUndefined(this.caps.newCommandTimeout)) {
if (this.caps.newCommandTimeout !== undefined) {
this.newCommandTimeoutMs = (this.caps.newCommandTimeout as number) * 1000;
}
@@ -388,7 +389,7 @@ export class BaseDriver<
// simple hack to release pending commands if they exist
// @ts-expect-error private API
const queues = this.commandsQueueGuard.queues;
for (const key of _.keys(queues)) {
for (const key of Object.keys(queues)) {
queues[key] = [];
}
}
@@ -396,8 +397,9 @@ export class BaseDriver<
}
logExtraCaps(caps: Capabilities<C>) {
const knownCaps = _.keys(this._desiredCapConstraints);
const extraCaps = _.difference(_.keys(caps), knownCaps);
const knownCaps = Object.keys(this._desiredCapConstraints);
const knownCapsSet = new Set(knownCaps);
const extraCaps = Object.keys(caps).filter((cap) => !knownCapsSet.has(cap));
if (extraCaps.length) {
this.log.warn(`The following provided capabilities were not recognized by this driver:`);
for (const cap of extraCaps) {
@@ -1,9 +1,12 @@
import {logger} from '@appium/support';
import {logger, util} from '@appium/support';
import {EventEmitter} from 'node:events';
import type {
AppiumLogger,
BidiModuleMap,
BiDiResultData,
IAppiumIpc,
IIpcSubscription,
IpcData,
StringRecord,
} from '@appium/types';
import {
@@ -11,7 +14,6 @@ import {
} from '../constants';
import {errors} from '../protocol';
import {BIDI_COMMANDS} from '../protocol/bidi-commands';
import _ from 'lodash';
import {generateDriverLogPrefix} from './helpers';
export class ExtensionCore {
@@ -21,6 +23,7 @@ export class ExtensionCore {
// used to handle driver events
readonly eventEmitter: NodeJS.EventEmitter;
protected _log: AppiumLogger;
private ipc?: IAppiumIpc;
constructor(logPrefix?: string) {
@@ -41,7 +44,7 @@ export class ExtensionCore {
}
updateBidiCommands(cmds: BidiModuleMap): void {
const overlappingKeys = _.intersection(Object.keys(cmds), Object.keys(this.bidiCommands));
const overlappingKeys = Object.keys(cmds).filter((key) => key in this.bidiCommands);
if (overlappingKeys.length) {
this.log.warn(`Overwriting existing bidi modules: ${JSON.stringify(overlappingKeys)}. This may not be intended!`);
}
@@ -98,7 +101,7 @@ export class ExtensionCore {
const args: any[] = [];
if (params?.required?.length) {
for (const requiredParam of params.required) {
if (_.isUndefined(bidiParams[requiredParam])) {
if (bidiParams[requiredParam] === undefined) {
throw new errors.InvalidArgumentError(
`The ${requiredParam} parameter was required but you omitted it`,
);
@@ -111,18 +114,41 @@ export class ExtensionCore {
args.push(bidiParams[optionalParam]);
}
}
const logParams = _.truncate(JSON.stringify(bidiParams), {length: MAX_LOG_BODY_LENGTH});
const logParams = util.truncateString(JSON.stringify(bidiParams), {length: MAX_LOG_BODY_LENGTH});
this.log.debug(
`Executing bidi command '${bidiCmd}' with params ${logParams} by passing to ${handlerType} ` +
`method '${command}'`,
);
// call the handler with the signature appropriate to extension type (plugin or driver)
const response = (next && driver) ? await this[command](next, driver, ...args) : await this[command](...args);
const finalResponse = _.isUndefined(response) ? {} : response;
const finalResponse = response === undefined ? {} : response;
this.log.debug(
`Responding to bidi command '${bidiCmd}' with ` +
`${_.truncate(JSON.stringify(finalResponse), {length: MAX_LOG_BODY_LENGTH})}`
`${util.truncateString(JSON.stringify(finalResponse), {length: MAX_LOG_BODY_LENGTH})}`
);
return finalResponse;
}
/**
* @internal Used by AppiumDriver to wire session IPC; extension authors should use {@link onIpcInit} instead.
*/
async assignIpc(ipc: IAppiumIpc): Promise<void> {
this.ipc = ipc;
try {
await this.onIpcInit();
} catch (e) {
this.log.error(`Error running onIpcInit: `, e);
}
}
async onIpcInit(): Promise<void> {}
ipcSubscribe<T extends IpcData>(topic: string): IIpcSubscription<T> {
if (!this.ipc) {
throw new Error(`Cannot subscribe to an IPC topic without an IPC object assigned. ` +
`This is likely a programming error. ipcSubscribe should be called in the ` +
`onIpcInit handler or after you are certain that createSession has completed successfully.`);
}
return this.ipc.subscribe<T>(topic, generateDriverLogPrefix(this));
}
}
+28 -30
View File
@@ -1,11 +1,9 @@
import _ from 'lodash';
import path from 'node:path';
import {log as logger} from './logger';
import {tempDir, fs, util, timing, node} from '@appium/support';
import {LRUCache} from 'lru-cache';
import AsyncLock from 'async-lock';
import axios from 'axios';
import B from 'bluebird';
import type {
ConfigureAppOptions,
CachedAppInfo,
@@ -102,27 +100,27 @@ export async function configureApp(
app: string,
options: string | string[] | ConfigureAppOptions = {} as ConfigureAppOptions
): Promise<string> {
if (!_.isString(app)) {
if (typeof app !== 'string') {
// immediately shortcircuit if not given an app
return '';
}
let supportedAppExtensions: string[];
const opts = !_.isString(options) && !_.isArray(options) ? options : undefined;
const opts = typeof options !== 'string' && !Array.isArray(options) ? options : undefined;
const onPostProcess = opts?.onPostProcess;
const onDownload = opts?.onDownload;
if (_.isString(options)) {
if (typeof options === 'string') {
supportedAppExtensions = [options];
} else if (_.isArray(options)) {
} else if (Array.isArray(options)) {
supportedAppExtensions = options;
} else if (_.isPlainObject(options)) {
} else if (util.isPlainObject(options)) {
supportedAppExtensions = options.supportedExtensions ?? [];
} else {
supportedAppExtensions = [];
}
if (_.isEmpty(supportedAppExtensions)) {
if (util.isEmpty(supportedAppExtensions)) {
throw new Error(`One or more supported app extensions must be provided`);
}
@@ -170,7 +168,7 @@ export async function configureApp(
let {stream, status} = result;
logger.debug(`Response status: ${status}`);
try {
if (!_.isEmpty(headers)) {
if (!util.isEmpty(headers)) {
if (headers.etag) {
logger.debug(`Etag: ${headers.etag}`);
remoteAppProps.etag = headers.etag;
@@ -212,7 +210,7 @@ export async function configureApp(
if (onDownload) {
newApp = await onDownload({
url: originalAppLink,
headers: _.clone(headers) as HTTPHeaders,
headers: structuredClone(headers) as HTTPHeaders,
stream,
});
} else {
@@ -233,7 +231,7 @@ export async function configureApp(
} else {
let errorMessage = `The application at '${newApp}' does not exist or is not accessible`;
// protocol value for 'C:\\temp' is 'c:', so we check the length as well
if (_.isString(protocol) && protocol.length > 2) {
if (typeof protocol === 'string' && protocol.length > 2) {
errorMessage =
`The protocol '${protocol}' used in '${newApp}' is not supported. ` +
`Only http: and https: protocols are supported`;
@@ -267,12 +265,12 @@ export async function configureApp(
return appPathToCache;
};
if (_.isFunction(onPostProcess)) {
if (typeof onPostProcess === 'function') {
const postProcessArg: PostProcessOptions = {
cachedAppInfo: _.clone(cachedAppInfo) as CachedAppInfo | undefined,
cachedAppInfo: structuredClone(cachedAppInfo) as CachedAppInfo | undefined,
isUrl,
originalAppLink,
headers: _.clone(headers) as HTTPHeaders,
headers: structuredClone(headers) as HTTPHeaders,
appPath: newApp,
};
const result = await onPostProcess(postProcessArg);
@@ -282,7 +280,7 @@ export async function configureApp(
}
verifyAppExtension(newApp, supportedAppExtensions);
return appCacheKey !== toCacheKey(newApp) && (packageHash || _.values(remoteAppProps).some(Boolean))
return appCacheKey !== toCacheKey(newApp) && (packageHash || Object.values(remoteAppProps).some(Boolean))
? await storeAppInCache(newApp)
: newApp;
});
@@ -310,14 +308,14 @@ export function isPackageOrBundle(app: string): boolean {
*/
export function duplicateKeys<T>(input: T, firstKey: string, secondKey: string): T {
// If array provided, recursively call on all elements
if (_.isArray(input)) {
if (Array.isArray(input)) {
return input.map((item) => duplicateKeys(item, firstKey, secondKey)) as T;
}
// If object, create duplicates for keys and then recursively call on values
if (_.isPlainObject(input)) {
if (util.isPlainObject(input)) {
const resultObj: Record<string, unknown> = {};
for (const [key, value] of _.toPairs(input as Record<string, unknown>)) {
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
const recursivelyCalledValue = duplicateKeys(value, firstKey, secondKey);
if (key === firstKey) {
resultObj[secondKey] = recursivelyCalledValue;
@@ -342,23 +340,23 @@ export function duplicateKeys<T>(input: T, firstKey: string, secondKey: string):
* @throws {TypeError} If value is not a string/array or JSON parsing fails for array-like input.
*/
export function parseCapsArray(capValue: string | string[]): string[] {
if (_.isArray(capValue)) {
if (Array.isArray(capValue)) {
return capValue;
}
try {
const parsed = JSON.parse(capValue);
if (_.isArray(parsed)) {
if (Array.isArray(parsed)) {
return parsed;
}
} catch (e) {
const message = `Failed to parse capability as JSON array: ${(e as Error).message}`;
if (_.isString(capValue) && _.startsWith(_.trimStart(capValue), '[')) {
if (typeof capValue === 'string' && capValue.trimStart().startsWith('[')) {
throw new TypeError(message, {cause: e});
}
logger.warn(message);
}
if (_.isString(capValue)) {
if (typeof capValue === 'string') {
return [capValue];
}
throw new TypeError(`Expected a string or a valid JSON array; received '${capValue}'`);
@@ -392,10 +390,10 @@ function parseAppLink(appLink: string): URL | {protocol?: string; pathname?: str
function isEnvOptionEnabled(optionName: string, defaultValue: boolean | null = null): boolean {
const value = process.env[optionName];
if (!_.isNull(defaultValue) && _.isEmpty(value)) {
if (defaultValue !== null && util.isEmpty(value)) {
return defaultValue;
}
return !_.isEmpty(value) && !['0', 'false', 'no'].includes(_.toLower(value));
return !util.isEmpty(value) && !['0', 'false', 'no'].includes(String(value).toLowerCase());
}
function isSupportedUrl(app: string): boolean {
@@ -466,7 +464,7 @@ async function fetchApp(srcStream: Readable, dstPath: string): Promise<string> {
const writer = fs.createWriteStream(dstPath);
srcStream.pipe(writer);
await new B<void>((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
srcStream.once('error', reject);
writer.once('finish', () => resolve());
writer.once('error', (e: Error) => {
@@ -515,18 +513,18 @@ function determineFilename(
? basename.substring(0, basename.length - extname.length)
: DEFAULT_BASENAME;
let resultingExt = extname;
if (!supportedAppExtensions.map(_.toLower).includes(_.toLower(resultingExt))) {
if (!supportedAppExtensions.map((ext) => ext.toLowerCase()).includes(resultingExt.toLowerCase())) {
logger.info(
`The current file extension '${resultingExt}' is not supported. ` +
`Defaulting to '${_.first(supportedAppExtensions)}'`
`Defaulting to '${supportedAppExtensions[0]}'`
);
resultingExt = _.first(supportedAppExtensions) as string;
resultingExt = supportedAppExtensions[0] as string;
}
return `${resultingName}${resultingExt}`;
}
function verifyAppExtension(app: string, supportedAppExtensions: string[]): string {
if (supportedAppExtensions.map(_.toLower).includes(_.toLower(path.extname(app)))) {
if (supportedAppExtensions.map((ext) => ext.toLowerCase()).includes(path.extname(app).toLowerCase())) {
return app;
}
throw new Error(
@@ -565,7 +563,7 @@ async function isAppIntegrityOk(
}
function toNaturalNumber(defaultValue: number, envVarName?: string): number {
if (!envVarName || _.isUndefined(process.env[envVarName])) {
if (!envVarName || process.env[envVarName] === undefined) {
return defaultValue;
}
const num = parseInt(`${process.env[envVarName]}`, 10);
+179
View File
@@ -0,0 +1,179 @@
import {log} from './logger';
import type {StringRecord, IIpcSubscription, IAppiumIpc, IpcMessage, IpcEvent, AppiumLogger, IpcData} from '@appium/types';
import EventEmitter from 'node:events';
import {sleep} from 'asyncbox';
import {node} from '@appium/support';
const DEF_MAX_OBJ_SIZE_BYTES = 1024 * 1024; // 1mb seems like plenty for any plugin to pass a message
const DEF_MAX_TOPICS = 1000;
export const EVT_MESSAGE = 'message';
export const EVT_UNSUBSCRIBED = 'unsubscribed';
export type AppiumIpcOpts = {
maxObjSize?: number;
maxTopics?: number;
log?: AppiumLogger;
};
const ASYNC_ITERATOR_STOP = Symbol('asyncIteratorStop');
export class IpcSubscription<T extends IpcData> extends EventEmitter<IpcEvent<T>> implements IIpcSubscription<T> {
constructor(
public readonly subscriber: string,
public readonly topic: string,
private readonly ipc: AppiumIpc
) {
super();
}
get isActive() {
return this.ipc.subscriptionExists(this.topic, this.subscriber);
}
getMessage(): IpcMessage<T> | undefined {
if (!this.isActive) {
throw new Error('Cannot get message from subscription after unsubscribing');
}
return this.ipc.getMessage<T>(this.topic);
}
async publish(data: T): Promise<void> {
if (!this.isActive) {
throw new Error('Cannot publish data to topic from subscription after unsubscribing');
}
return await this.ipc.publish<T>(this.topic, this.subscriber, data);
}
unsubscribe(): boolean {
if (!this.isActive) {
return false;
}
const unsubscribeRes = this.ipc.unsubscribe(this.topic, this.subscriber);
this.emit('unsubscribed');
this.removeAllListeners(EVT_MESSAGE);
return unsubscribeRes;
}
async *[Symbol.asyncIterator](): AsyncGenerator<IpcMessage<T>> {
// yield any messages that are emitted, but keep an eye out for an unsubscribed that happens
// while a caller is waiting on the loop, because we want to exit the loop in case of
// unsubscription, even if we were already waiting on the next message.
while (this.isActive) {
const val = await new Promise<IpcMessage<T> | typeof ASYNC_ITERATOR_STOP>((resolve) => {
this.once(EVT_MESSAGE, (message: IpcMessage<T>) => {
this.removeAllListeners(EVT_UNSUBSCRIBED);
resolve(message);
});
this.once(EVT_UNSUBSCRIBED, () => {
// EVT_MESSAGE listeners are already removed in unsubscribe()
resolve(ASYNC_ITERATOR_STOP);
});
});
if (val === ASYNC_ITERATOR_STOP) {
break;
}
yield val;
}
}
}
export class AppiumIpc implements IAppiumIpc {
protected readonly messageByTopic: StringRecord<IpcMessage<any>> = {};
protected readonly subs: StringRecord<Array<IpcSubscription<any>>> = {};
protected readonly topics = new Set<string>();
protected readonly maxObjSize: number;
protected readonly maxTopics: number;
protected readonly log: AppiumLogger;
constructor (opts: AppiumIpcOpts = {}) {
this.maxObjSize = opts.maxObjSize ?? DEF_MAX_OBJ_SIZE_BYTES;
this.maxTopics = opts.maxTopics ?? DEF_MAX_TOPICS;
this.log = opts.log ?? log;
this.log.debug(`Initialized new IPC object with max object size of ${this.maxObjSize} bytes ` +
`and max topics of ${this.maxTopics}`);
}
subscribe<T extends IpcData>(topic: string, subscriber: string): IpcSubscription<T> {
this.log.info(`Subscribing ${subscriber} to topic '${topic}'`);
if (this.subscriptionExists(topic, subscriber)) {
throw new Error(`Subscription already exists for topic "${topic}" and subscriber "${subscriber}"`);
}
this.ensureTopic(topic);
this.subs[topic] ??= [];
const sub = new IpcSubscription<T>(subscriber, topic, this);
this.subs[topic].push(sub);
return sub;
}
unsubscribe(topic: string, subscriber: string): boolean {
this.log.info(`Unsubscribing ${subscriber} from topic '${topic}'`);
if (this.subscriptionExists(topic, subscriber)) {
this.subs[topic] = this.subs[topic].filter((sub) => sub.subscriber !== subscriber);
return true;
}
return false;
}
async publish<T extends IpcData>(topic: string, publisher: string, data: T): Promise<void> {
this.log.debug(`${publisher} is publishing a message to topic ${topic}`);
this.ensureTopic(topic);
const messageSize = node.getObjectSize(data);
if (messageSize > this.maxObjSize) {
throw new Error(`Error when ${publisher} is publishing to topic '${topic}': ` +
`Message with size ${messageSize} bytes is bigger than max size of ${this.maxObjSize} bytes`);
}
let clonedData: T;
try {
clonedData = structuredClone(data);
} catch (e) {
throw new Error(`Could not clone data for IPC publish from ${publisher} on topic ${topic}`, {cause: e});
}
const message: IpcMessage<T> = {publisher, data: clonedData, topic, timestampMs: Date.now()};
this.messageByTopic[topic] = message;
const subs = this.subs[topic] ?
this.subs[topic].filter((sub) => sub.subscriber !== publisher) :
[];
for (const sub of subs) {
sub.emit(EVT_MESSAGE, structuredClone(message));
}
// we don't want to return from publish until the async iterators on subscriptions have had
// a chance to observe the emitted value, otherwise some might get lost
await sleep(0);
}
getMessage<T extends IpcData>(topic: string): IpcMessage<T> | undefined {
if (!this.messageByTopic[topic]) {
return;
}
return structuredClone(this.messageByTopic[topic] as IpcMessage<T>);
}
subscriptionExists(topic: string, subscriber: string): boolean {
return !!this.subs[topic]?.some((sub) => sub.subscriber === subscriber);
}
protected ensureTopic(topic: string): void {
if (this.topics.has(topic)) {
return;
}
if (this.topics.size >= this.maxTopics) {
throw new Error(`Cannot create new IPC topic '${topic}': ` +
`maximum of ${this.maxTopics} topics per session reached. ` +
`Adjust with the --max-ipc-topics server arg.`);
}
this.topics.add(topic);
}
}
@@ -1,6 +1,6 @@
import type {Constraint} from '@appium/types';
import {util} from '@appium/support';
import {log} from './logger';
import _ from 'lodash';
export class Validator {
private readonly _validators: Record<
@@ -8,27 +8,27 @@ export class Validator {
(value: any, options?: any, key?: string) => string | null
> = {
isString: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || _.isNil(options)) {
if (value === undefined || options == null) {
return null;
}
if (_.isString(value)) {
if (typeof value === 'string') {
return options ? null : 'must not be of type string';
}
return options ? 'must be of type string' : null;
},
isNumber: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || _.isNil(options)) {
if (value === undefined || options == null) {
return null;
}
if (_.isNumber(value)) {
if (typeof value === 'number') {
return options ? null : 'must not be of type number';
}
// allow a string value
if (options && _.isString(value) && !isNaN(Number(value))) {
if (options && typeof value === 'string' && !isNaN(Number(value))) {
log.warn('Number capability passed in as string. Functionality may be compromised.');
return null;
}
@@ -36,45 +36,45 @@ export class Validator {
return options ? 'must be of type number' : null;
},
isBoolean: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || _.isNil(options)) {
if (value === undefined || options == null) {
return null;
}
if (_.isBoolean(value)) {
if (typeof value === 'boolean') {
return options ? null : 'must not be of type boolean';
}
// allow a string value
if (options && _.isString(value) && ['true', 'false', ''].includes(value)) {
if (options && typeof value === 'string' && ['true', 'false', ''].includes(value)) {
return null;
}
return options ? 'must be of type boolean' : null;
},
isObject: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || _.isNil(options)) {
if (value === undefined || options == null) {
return null;
}
if (_.isPlainObject(value)) {
if (util.isPlainObject(value)) {
return options ? null : 'must not be a plain object';
}
return options ? 'must be a plain object' : null;
},
isArray: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || _.isNil(options)) {
if (value === undefined || options == null) {
return null;
}
if (_.isArray(value)) {
if (Array.isArray(value)) {
return options ? null : 'must not be of type array';
}
return options ? 'must be of type array' : null;
},
deprecated: (value: any, options?: any, key?: string): string | null => {
if (!_.isUndefined(value) && options) {
if (value !== undefined && options) {
log.warn(
`The '${key}' capability has been deprecated and must not be used anymore. ` +
`Please check the driver documentation for possible alternatives.`
@@ -83,32 +83,32 @@ export class Validator {
return null;
},
inclusion: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || !options) {
if (value === undefined || !options) {
return null;
}
const optionsArr = _.isArray(options) ? options : [options];
const optionsArr = Array.isArray(options) ? options : [options];
if (optionsArr.some((opt) => opt === value)) {
return null;
}
return `must be contained by ${JSON.stringify(optionsArr)}`;
},
inclusionCaseInsensitive: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || !options) {
if (value === undefined || !options) {
return null;
}
const optionsArr = _.isArray(options) ? options : [options];
if (optionsArr.some((opt) => _.toLower(opt) === _.toLower(value))) {
const optionsArr = Array.isArray(options) ? options : [options];
if (optionsArr.some((opt) => String(opt).toLowerCase() === String(value).toLowerCase())) {
return null;
}
return `must be contained by ${JSON.stringify(optionsArr)}`;
},
presence: (value: any, options?: any): string | null => {
if (_.isUndefined(value) && options) {
if (value === undefined && options) {
return 'is required to be present';
}
if (
!options?.allowEmpty &&
((!_.isUndefined(value) && _.isEmpty(value)) || (_.isString(value) && !_.trim(value)))
((value !== undefined && util.isEmpty(value)) || (typeof value === 'string' && !value.trim()))
) {
return 'must not be empty or blank';
}
@@ -118,15 +118,15 @@ export class Validator {
validate(values: Record<string, any>, constraints: Record<string, Constraint>): Record<string, string[]> | null {
const result: Record<string, string[]> = {};
for (const [key, constraint] of _.toPairs(constraints)) {
for (const [key, constraint] of Object.entries(constraints)) {
const value = values[key];
for (const [validatorName, options] of _.toPairs(constraint)) {
for (const [validatorName, options] of Object.entries(constraint)) {
if (!(validatorName in this._validators)) {
continue;
}
const validationError = this._validators[validatorName](value, options, key);
if (_.isNil(validationError)) {
const validationError = this._validators[validatorName as keyof Constraint](value, options, key);
if (validationError == null) {
continue;
}
@@ -137,7 +137,7 @@ export class Validator {
}
}
}
return _.isEmpty(result) ? null : result;
return util.isEmpty(result) ? null : result;
}
}
@@ -1,10 +1,9 @@
import _ from 'lodash';
import {util, logger} from '@appium/support';
import '@colors/colors';
import morgan from 'morgan';
import type {Request, RequestHandler, Response} from 'express';
import {log} from './logger';
import {MAX_LOG_BODY_LENGTH} from '../constants';
import {logger} from '@appium/support';
/**
* Morgan middleware that logs when the HTTP response finishes.
@@ -32,8 +31,8 @@ function startLogFormatterHandler(tokens: unknown, req: Request, res: Response):
let reqBody = '';
if (req.body) {
try {
reqBody = _.truncate(
_.isString(req.body) ? req.body : JSON.stringify(req.body),
reqBody = util.truncateString(
typeof req.body === 'string' ? req.body : JSON.stringify(req.body),
{length: MAX_LOG_BODY_LENGTH}
);
} catch {
@@ -1,6 +1,6 @@
import {log} from './logger';
import {LRUCache} from 'lru-cache';
import _ from 'lodash';
import {util} from '@appium/support';
import {EventEmitter} from 'node:events';
import type {Request, Response, NextFunction} from 'express';
@@ -34,12 +34,12 @@ export async function handleIdempotency(
next: NextFunction
): Promise<void> {
const keyOrArr = req.headers[IDEMPOTENCY_KEY_HEADER];
if (_.isEmpty(keyOrArr) || !keyOrArr) {
if (util.isEmpty(keyOrArr) || !keyOrArr) {
next();
return;
}
const key = _.isArray(keyOrArr) ? keyOrArr[0] : keyOrArr;
const key = Array.isArray(keyOrArr) ? keyOrArr[0] : keyOrArr;
log.updateAsyncContext({idempotencyKey: key});
@@ -1,4 +1,4 @@
import _ from 'lodash';
import {util} from '@appium/support';
import type {NextFunction, Request, RequestHandler, Response} from 'express';
import type {IncomingMessage} from 'node:http';
import type {Duplex} from 'node:stream';
@@ -6,7 +6,6 @@ import {log} from './logger';
import {errors} from '../protocol';
export {handleIdempotency} from './idempotency';
import {match} from 'path-to-regexp';
import {util} from '@appium/support';
import {calcSignature} from '../helpers/session';
import {getResponseForW3CError} from '../protocol/errors';
import type {StringRecord, WSServer} from '@appium/types';
@@ -46,7 +45,7 @@ export function allowCrossDomainAsyncExecute(basePath: string): RequestHandler {
next: NextFunction
): void {
const receiveAsyncResponseRegExp = new RegExp(
`${_.escapeRegExp(basePath)}/session/[a-f0-9-]+/(appium/)?receive_async_response`
`${util.escapeRegExp(basePath)}/session/[a-f0-9-]+/(appium/)?receive_async_response`
);
if (!receiveAsyncResponseRegExp.test(req.url)) {
next();
@@ -73,7 +72,7 @@ export function handleLogContext(req: Request, _res: Response, next: NextFunctio
{
requestId,
...sessionInfo,
isSensitive: ['true', '1', 'yes'].includes(_.toLower(isSensitiveHeaderValue)),
isSensitive: ['true', '1', 'yes'].includes(String(isSensitiveHeaderValue ?? '').toLowerCase()),
},
true
);
@@ -110,7 +109,7 @@ export function tryHandleWebSocketUpgrade(
head: Buffer,
webSocketsMapping: StringRecord<WSServer>
): boolean {
if (_.toLower(req.headers?.upgrade) !== 'websocket') {
if (String(req.headers?.upgrade ?? '').toLowerCase() !== 'websocket') {
return false;
}
@@ -120,7 +119,7 @@ export function tryHandleWebSocketUpgrade(
} catch {
currentPathname = req.url ?? '';
}
for (const [pathname, wsServer] of _.toPairs(webSocketsMapping)) {
for (const [pathname, wsServer] of Object.entries(webSocketsMapping)) {
if (match(pathname)(currentPathname)) {
wsServer.handleUpgrade(req, socket, head, (ws) => {
wsServer.emit('connection', ws, req);
@@ -182,6 +181,5 @@ export function catch404Handler(req: Request, res: Response): void {
function fetchHeaderValue(req: Request, name: string): string | undefined {
const value = req.headers[name];
return _.isArray(value) ? value[0] : (value as string | undefined);
return Array.isArray(value) ? value[0] : (value as string | undefined);
}
+67 -61
View File
@@ -1,4 +1,3 @@
import _ from 'lodash';
import path from 'node:path';
import express from 'express';
import type {Express, RequestHandler} from 'express';
@@ -34,7 +33,6 @@ import {
removeAllWebSocketHandlers,
getWebSocketHandlers,
} from './websocket';
import B from 'bluebird';
import {DEFAULT_BASE_PATH} from '../constants';
import {fs, timing} from '@appium/support';
import type {
@@ -123,51 +121,51 @@ export async function server(opts: ServerOpts): Promise<AppiumServer> {
const app = express();
const httpServer = await createServer(app, cliArgs);
return await new B<AppiumServer>(async (resolve, reject) => {
// we put an async function as the promise constructor because we want some things to happen in
// serial (application of plugin updates, for example). But we still need to use a promise here
// because some elements of server start failure only happen in httpServer listeners. So the
// way we resolve it is to use an async function here but to wrap all the inner logic in
// try/catch so any errors can be passed to reject.
try {
const appiumServer = configureHttp({
httpServer,
reject,
keepAliveTimeout,
gracefulShutdownTimeout: cliArgs.shutdownTimeout,
});
const useLegacyUpgradeHandler = !hasShouldUpgradeCallback(httpServer);
configureServer({
app,
addRoutes: routeConfiguringFunction,
allowCors,
basePath,
extraMethodMap,
webSocketsMapping: appiumServer.webSocketsMapping,
useLegacyUpgradeHandler,
});
// allow extensions to update the app and http server objects
for (const updater of serverUpdaters) {
await updater(app, appiumServer, cliArgs);
return await new Promise<AppiumServer>((resolve, reject) => {
// we use a promise here because some elements of server start failure only happen in
// httpServer listeners. The async IIFE runs setup serially (e.g. plugin updates) while
// configureHttp can still reject via the listener registered below.
void (async () => {
try {
const appiumServer = configureHttp({
httpServer,
reject,
keepAliveTimeout,
gracefulShutdownTimeout: cliArgs.shutdownTimeout,
});
const useLegacyUpgradeHandler = !hasShouldUpgradeCallback(httpServer);
configureServer({
app,
addRoutes: routeConfiguringFunction,
allowCors,
basePath,
extraMethodMap,
webSocketsMapping: appiumServer.webSocketsMapping,
useLegacyUpgradeHandler,
});
// allow extensions to update the app and http server objects
for (const updater of serverUpdaters) {
await updater(app, appiumServer, cliArgs);
}
// once all configurations and updaters have been applied, make sure to set up a catchall
// handler so that anything unknown 404s. But do this after everything else since we don't
// want to block extensions' ability to add routes if they want.
app.all('/*all', catch404Handler);
await startServer({
httpServer,
hostname,
port,
keepAliveTimeout,
requestTimeout,
});
resolve(appiumServer);
} catch (err) {
reject(err);
}
// once all configurations and updaters have been applied, make sure to set up a catchall
// handler so that anything unknown 404s. But do this after everything else since we don't
// want to block extensions' ability to add routes if they want.
app.all('/*all', catch404Handler);
await startServer({
httpServer,
hostname,
port,
keepAliveTimeout,
requestTimeout,
});
resolve(appiumServer);
} catch (err) {
reject(err);
}
})();
});
}
@@ -237,7 +235,7 @@ export function configureServer({
* @returns Normalized base path
*/
export function normalizeBasePath(basePath: string): string {
if (!_.isString(basePath)) {
if (typeof basePath !== 'string') {
throw new Error(`Invalid path prefix ${basePath}`);
}
@@ -268,19 +266,18 @@ async function createServer(
}
const certKey = [sslCertificatePath, sslKeyPath];
const zipped = _.zip(
await B.all(certKey.map((p) => fs.exists(p))),
['certificate', 'key'],
certKey
) as [boolean, string, string][];
for (const [exists, desc, p] of zipped) {
const [certExists, keyExists] = await Promise.all(certKey.map((p) => fs.exists(p)));
for (const [exists, desc, p] of [
[certExists, 'certificate', sslCertificatePath],
[keyExists, 'key', sslKeyPath],
]) {
if (!exists) {
throw new Error(
`The provided SSL ${desc} at '${p}' does not exist or is not accessible`
);
}
}
const [cert, key] = await B.all(
const [cert, key] = await Promise.all(
certKey.map((p) => fs.readFile(p, 'utf8'))
) as [string, string];
log.debug('Enabling TLS/SPDY on the server using the provided certificate');
@@ -333,7 +330,7 @@ function configureHttp({
if (hasShouldUpgradeCallback(httpServer)) {
// shouldUpgradeCallback only returns a boolean to indicate if the upgrade should proceed
(appiumServer as unknown as {shouldUpgradeCallback?: (req: http.IncomingMessage) => boolean}).shouldUpgradeCallback = (req) =>
_.toLower(req.headers?.upgrade) === 'websocket';
String(req.headers?.upgrade ?? '').toLowerCase() === 'websocket';
appiumServer.on('upgrade', (req, socket, head) => {
if (!tryHandleWebSocketUpgrade(req, socket, head, appiumServer.webSocketsMapping)) {
socket.destroy();
@@ -345,7 +342,7 @@ function configureHttp({
// all connections are closed and the `close` event is emitted
const originalClose = appiumServer.close.bind(appiumServer);
appiumServer.close = async () =>
await new B<void>((_resolve, _reject) => {
await new Promise<void>((_resolve, _reject) => {
log.info('Closing Appium HTTP server');
const timer = new timing.Timer().start();
const onTimeout = setTimeout(() => {
@@ -405,12 +402,21 @@ async function startServer({
requestTimeout,
}: StartServerOpts): Promise<void> {
// If the hostname is omitted, the server will accept connections on any IP address
const start = B.promisify(httpServer.listen, {
context: httpServer,
}) as (port: number, hostname?: string) => B<HttpServer>;
const startPromise = start(port, hostname);
const startPromise = new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
httpServer.removeListener('listening', onListening);
reject(err);
};
const onListening = () => {
httpServer.removeListener('error', onError);
resolve();
};
httpServer.once('error', onError);
httpServer.once('listening', onListening);
httpServer.listen(port, hostname);
});
httpServer.keepAliveTimeout = keepAliveTimeout;
if (_.isInteger(requestTimeout)) {
if (Number.isInteger(requestTimeout)) {
httpServer.requestTimeout = Number(requestTimeout);
}
// headers timeout must be greater than keepAliveTimeout
+15 -7
View File
@@ -1,13 +1,11 @@
import path from 'node:path';
import {log} from './logger';
import _ from 'lodash';
import {fs} from '@appium/support';
import B from 'bluebird';
import {sleep} from 'asyncbox';
import type {Request, Response} from 'express';
import {compileLodashTemplate} from '../utils';
export const STATIC_DIR = _.isNull(path.resolve(__dirname).match(/build[/\\]lib[/\\]express$/))
? path.resolve(__dirname, '..', '..', 'static')
: path.resolve(__dirname, '..', '..', '..', 'static');
export const STATIC_DIR = resolveStaticDir();
type TemplateParams = Record<string, unknown>;
@@ -56,7 +54,7 @@ async function guineaPigTemplate(
log.debug(`Sending guinea pig response with params: ${JSON.stringify(params)}`);
if (delay) {
log.debug(`Waiting ${delay}ms before responding`);
await B.delay(delay);
await sleep(delay);
}
res.set('content-type', 'text/html');
res.cookie('guineacookie1', 'i am a cookie value', {path: '/'});
@@ -73,5 +71,15 @@ async function getTemplate(
templateName: string
): Promise<(params: TemplateParams) => string> {
const content = await fs.readFile(path.resolve(STATIC_DIR, 'test', templateName));
return _.template(content.toString()) as (params: TemplateParams) => string;
return compileLodashTemplate(content.toString());
}
function resolveStaticDir(): string {
const fromDir = __dirname;
const parts = path.resolve(fromDir).split(path.sep);
const baseDriverIndex = parts.indexOf('base-driver');
if (baseDriverIndex < 0) {
throw new Error(`Could not find the module root folder in the path: ${fromDir}`);
}
return path.join(parts.slice(0, baseDriverIndex + 1).join(path.sep), 'static');
}
+8 -10
View File
@@ -1,5 +1,4 @@
import _ from 'lodash';
import B from 'bluebird';
import {util} from '@appium/support';
import type {AppiumServer, WSServer} from '@appium/types';
export const DEFAULT_WS_PATHNAME_PREFIX = '/ws';
@@ -24,9 +23,9 @@ export async function getWebSocketHandlers(
this: AppiumServer,
keysFilter: string | null = null
): Promise<Record<string, WSServer>> {
return _.toPairs(this.webSocketsMapping).reduce<Record<string, WSServer>>(
return Object.entries(this.webSocketsMapping).reduce<Record<string, WSServer>>(
(acc, [pathname, wsServer]) => {
if (!_.isString(keysFilter) || pathname.includes(keysFilter)) {
if (typeof keysFilter !== 'string' || pathname.includes(keysFilter)) {
acc[pathname] = wsServer;
}
return acc;
@@ -67,15 +66,14 @@ export async function removeWebSocketHandler(
* @see AppiumServerExtension.removeAllWebSocketHandlers
*/
export async function removeAllWebSocketHandlers(this: AppiumServer): Promise<boolean> {
if (_.isEmpty(this.webSocketsMapping)) {
if (util.isEmpty(this.webSocketsMapping)) {
return false;
}
return _.some(
await B.all(
_.keys(this.webSocketsMapping).map((pathname) =>
this.removeWebSocketHandler(pathname)
)
const results = await Promise.all(
Object.keys(this.webSocketsMapping).map((pathname) =>
this.removeWebSocketHandler(pathname)
)
);
return results.some(Boolean);
}
@@ -1,28 +1,28 @@
import type {Constraints, W3CCapabilities, Capabilities, AppiumLogger} from '@appium/types';
import _ from 'lodash';
import {util} from '@appium/support';
/**
* Determine whether the given argument is valid
* W3C capabilities instance.
*/
export function isW3cCaps(caps: unknown): caps is W3CCapabilities<Constraints> {
if (!_.isPlainObject(caps)) {
if (!util.isPlainObject(caps)) {
return false;
}
const c = caps as Record<string, unknown>;
const isFirstMatchValid = () =>
_.isArray(c.firstMatch) &&
!_.isEmpty(c.firstMatch) &&
_.every(c.firstMatch, _.isPlainObject);
const isAlwaysMatchValid = () => _.isPlainObject(c.alwaysMatch);
if (_.has(c, 'firstMatch') && _.has(c, 'alwaysMatch')) {
Array.isArray(c.firstMatch) &&
!util.isEmpty(c.firstMatch) &&
c.firstMatch.every((item) => util.isPlainObject(item));
const isAlwaysMatchValid = () => util.isPlainObject(c.alwaysMatch);
if (Object.hasOwn(c, 'firstMatch') && Object.hasOwn(c, 'alwaysMatch')) {
return isFirstMatchValid() && isAlwaysMatchValid();
}
if (_.has(c, 'firstMatch')) {
if (Object.hasOwn(c, 'firstMatch')) {
return isFirstMatchValid();
}
if (_.has(c, 'alwaysMatch')) {
if (Object.hasOwn(c, 'alwaysMatch')) {
return isAlwaysMatchValid();
}
return false;
@@ -36,16 +36,18 @@ export function fixCaps<C extends Constraints>(
desiredCapConstraints: C,
log: AppiumLogger
): Capabilities<C> {
const caps = _.clone(oldCaps) as Record<string, unknown>;
const caps = {...oldCaps} as Record<string, unknown>;
const logCastWarning = (prefix: string) => log.warn(`${prefix}. This may cause unexpected behavior`);
// boolean capabilities can be passed in as strings 'false' and 'true'
// which we want to translate into boolean values
const booleanCaps = _.keys(_.pickBy(desiredCapConstraints, (k) => k.isBoolean === true));
const booleanCaps = Object.keys(desiredCapConstraints).filter(
(key) => desiredCapConstraints[key as keyof C]?.isBoolean === true
);
for (const cap of booleanCaps) {
const value = oldCaps[cap];
if (!_.isString(value)) {
if (typeof value !== 'string') {
continue;
}
@@ -59,10 +61,12 @@ export function fixCaps<C extends Constraints>(
}
// int capabilities are often sent in as strings by frameworks
const intCaps = _.keys(_.pickBy(desiredCapConstraints, (k) => k.isNumber === true));
const intCaps = Object.keys(desiredCapConstraints).filter(
(key) => desiredCapConstraints[key as keyof C]?.isNumber === true
);
for (const cap of intCaps) {
const value = oldCaps[cap];
if (!_.isString(value)) {
if (typeof value !== 'string') {
continue;
}
@@ -1,4 +1,4 @@
import _ from 'lodash';
import {util} from '@appium/support';
import type {Constraints, Driver, DriverClass} from '@appium/types';
import type {BaseDriver} from '../basedriver/driver';
@@ -16,7 +16,7 @@ export function resolveExecuteExtensionName<C extends Constraints>(
const Driver = this.constructor as DriverClass<Driver<C>>;
const methodMap = Driver.executeMethodMap;
if (methodMap && _.isPlainObject(methodMap) && commandName in methodMap) {
if (methodMap && util.isPlainObject(methodMap) && commandName in methodMap) {
const command = methodMap[commandName]?.command;
if (typeof command === 'string') {
return command;
@@ -1,6 +1,5 @@
import type {StringRecord} from '@appium/types';
import {distance} from 'fastest-levenshtein';
import _ from 'lodash';
/**
* Inclusive maximum Levenshtein edit distance for offering a "did you mean" hint.
@@ -39,10 +38,8 @@ export function rankLevenshteinCandidates(
}
return acc;
}, {});
const sortedDistanceKeys = _.keys(matchesMap).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
const sorted = _.flatten(
sortedDistanceKeys.map((k) => (matchesMap[k] ?? []).sort()),
);
const sortedDistanceKeys = Object.keys(matchesMap).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
const sorted = sortedDistanceKeys.flatMap((k) => (matchesMap[k] ?? []).sort());
const best = sorted[0];
const firstDistanceKey = sortedDistanceKeys[0];
+1 -11
View File
@@ -1,19 +1,9 @@
import B from 'bluebird';
try {
B.config({
cancellation: true,
});
} catch {
// sometimes during testing this somehow gets required twice and results in an error about
// cancellation not being able to be enabled after promise has been configured
}
// BaseDriver exports
export {ExtensionCore} from './basedriver/extension-core';
import {BaseDriver} from './basedriver/driver';
export {DriverCore} from './basedriver/core';
export {DeviceSettings} from './basedriver/device-settings';
export {AppiumIpc} from './basedriver/ipc';
export {BaseDriver};
export default BaseDriver;
@@ -1,5 +1,4 @@
import type {AppiumLogger, HTTPBody, ProxyResponse} from '@appium/types';
import _ from 'lodash';
import {logger, util} from '@appium/support';
import {duplicateKeys} from '../basedriver/helpers';
import {MJSONWP_ELEMENT_KEY, W3C_ELEMENT_KEY, PROTOCOLS} from '../constants';
@@ -138,12 +137,12 @@ export class ProtocolConverter {
* provided in the request, we need to do 3 proxies and combine the result.
*/
private getTimeoutRequestObjects(body: HTTPBody): Record<string, unknown>[] {
if (_.isNil(body)) {
if (body == null) {
return [];
}
const bodyObj = (util.safeJsonParse(body) as Record<string, unknown>) ?? {};
if (this.downstreamProtocol === W3C && _.has(bodyObj, 'ms') && _.has(bodyObj, 'type')) {
if (this.downstreamProtocol === W3C && Object.hasOwn(bodyObj, 'ms') && Object.hasOwn(bodyObj, 'type')) {
const typeToW3C = (x: string) => (x === 'page load' ? 'pageLoad' : x);
return [
{
@@ -152,9 +151,9 @@ export class ProtocolConverter {
];
}
if (this.downstreamProtocol === MJSONWP && (!_.has(bodyObj, 'ms') || !_.has(bodyObj, 'type'))) {
if (this.downstreamProtocol === MJSONWP && (!Object.hasOwn(bodyObj, 'ms') || !Object.hasOwn(bodyObj, 'type'))) {
const typeToJSONWP = (x: string) => (x === 'pageLoad' ? 'page load' : x);
return _.toPairs(bodyObj)
return Object.entries(bodyObj)
// Only transform the entry if ms value is a valid positive float number
.filter((pair) => /^\d+(?:[.,]\d*?)?$/.test(`${pair[1]}`))
.map((pair) => ({
@@ -206,16 +205,16 @@ export class ProtocolConverter {
body: HTTPBody
): Promise<[ProxyResponse, HTTPBody]> {
const bodyObj = util.safeJsonParse(body);
if (_.isPlainObject(bodyObj)) {
if (util.isPlainObject(bodyObj)) {
const obj = bodyObj as Record<string, unknown>;
if (this.downstreamProtocol === W3C && _.has(bodyObj, 'name') && !_.has(bodyObj, 'handle')) {
if (this.downstreamProtocol === W3C && Object.hasOwn(bodyObj, 'name') && !Object.hasOwn(bodyObj, 'handle')) {
this.log.debug(`Copied 'name' value '${obj.name}' to 'handle' as per W3C spec`);
return await this.proxyFunc(url, method, {...obj, handle: obj.name});
}
if (
this.downstreamProtocol === MJSONWP &&
_.has(bodyObj, 'handle') &&
!_.has(bodyObj, 'name')
Object.hasOwn(bodyObj, 'handle') &&
!Object.hasOwn(bodyObj, 'name')
) {
this.log.debug(`Copied 'handle' value '${obj.handle}' to 'name' as per JSONWP spec`);
return await this.proxyFunc(url, method, {...obj, name: obj.handle});
@@ -230,13 +229,13 @@ export class ProtocolConverter {
body: HTTPBody
): Promise<[ProxyResponse, HTTPBody]> {
const bodyObj = util.safeJsonParse(body) as Record<string, unknown> | undefined;
if (_.isPlainObject(bodyObj) && (util.hasValue(bodyObj?.text) || util.hasValue(bodyObj?.value))) {
if (util.isPlainObject(bodyObj) && (util.hasValue(bodyObj?.text) || util.hasValue(bodyObj?.value))) {
let {text, value} = bodyObj;
if (util.hasValue(text) && !util.hasValue(value)) {
value = _.isString(text) ? [...text] : _.isArray(text) ? text : [];
value = typeof text === 'string' ? [...text] : Array.isArray(text) ? text : [];
this.log.debug(`Added 'value' property to 'setValue' request body`);
} else if (!util.hasValue(text) && util.hasValue(value)) {
text = _.isArray(value) ? value.join('') : _.isString(value) ? value : '';
text = Array.isArray(value) ? value.join('') : typeof value === 'string' ? value : '';
this.log.debug(`Added 'text' property to 'setValue' request body`);
}
return await this.proxyFunc(url, method, {...bodyObj, text, value});
@@ -250,10 +249,10 @@ export class ProtocolConverter {
body: HTTPBody
): Promise<[ProxyResponse, HTTPBody]> {
const bodyObj = util.safeJsonParse(body);
if (_.has(bodyObj, 'id') && _.isPlainObject(bodyObj.id)) {
if (Object.hasOwn(bodyObj ?? {}, 'id') && util.isPlainObject((bodyObj as Record<string, unknown>).id)) {
return await this.proxyFunc(url, method, {
...(bodyObj as object),
id: duplicateKeys(bodyObj.id as object, MJSONWP_ELEMENT_KEY, W3C_ELEMENT_KEY),
id: duplicateKeys((bodyObj as Record<string, unknown>).id as object, MJSONWP_ELEMENT_KEY, W3C_ELEMENT_KEY),
});
}
return await this.proxyFunc(url, method, body);
@@ -265,7 +264,7 @@ export class ProtocolConverter {
body: HTTPBody
): Promise<[ProxyResponse, HTTPBody]> {
const bodyObj = util.safeJsonParse(body);
if (_.isPlainObject(bodyObj)) {
if (util.isPlainObject(bodyObj)) {
return await this.proxyFunc(
url,
method,
@@ -1,19 +1,16 @@
import axios from 'axios';
import { CancellationError } from 'bluebird';
import EventEmitter from 'node:events';
const CANCEL_EVENT = 'cancel';
const FINISH_EVENT = 'finish';
export class ProxyRequest {
private readonly _requestConfig: axios.RawAxiosRequestConfig;
private readonly _ee: EventEmitter;
private _resultPromise: Promise<any> | null;
private _resultPromise: Promise<axios.AxiosResponse> | null;
private _abortController: AbortController | null;
private _cancelled: boolean;
constructor(requestConfig: axios.RawAxiosRequestConfig<any>) {
this._requestConfig = requestConfig;
this._ee = new EventEmitter();
this._resultPromise = null;
this._abortController = null;
this._cancelled = false;
}
async execute(): Promise<axios.AxiosResponse> {
@@ -21,32 +18,35 @@ export class ProxyRequest {
return await this._resultPromise;
}
const abortController = new AbortController();
this._abortController = abortController;
this._cancelled = false;
try {
this._resultPromise = Promise.race([
this._makeRacingTimer(),
this._makeRequest(),
]);
this._resultPromise = this._makeRequest(abortController.signal);
return await this._resultPromise;
} finally {
this._ee.emit(FINISH_EVENT);
this._ee.removeAllListeners();
this._abortController = null;
}
}
cancel(): void {
this._ee.emit(CANCEL_EVENT);
this._cancelled = true;
this._abortController?.abort();
}
private async _makeRequest(): Promise<axios.AxiosResponse> {
return await axios(this._requestConfig);
}
private async _makeRacingTimer(): Promise<void> {
return await new Promise((resolve, reject) => {
this._ee.once(FINISH_EVENT, resolve);
this._ee.once(CANCEL_EVENT, () => reject(new CancellationError(
'The request has been cancelled'
)));
});
private async _makeRequest(signal: AbortSignal): Promise<axios.AxiosResponse> {
try {
return await axios({
...this._requestConfig,
signal,
});
} catch (err) {
if (this._cancelled && axios.isCancel(err)) {
// The request was cancelled; do not propagate the error to callers.
return await new Promise<axios.AxiosResponse>(() => {});
}
throw err;
}
}
}
+36 -37
View File
@@ -6,7 +6,6 @@ import type {
ProxyOptions,
ProxyResponse,
} from '@appium/types';
import _ from 'lodash';
import {logger, util} from '@appium/support';
import {getSummaryByCode} from '../jsonwp-status/status';
import {
@@ -19,6 +18,7 @@ import {
import {isSessionCommand, routeToCommandName} from '../protocol';
import {MAX_LOG_BODY_LENGTH, DEFAULT_BASE_PATH, PROTOCOLS} from '../constants';
import {ProtocolConverter} from './protocol-converter';
import {omit, pick} from '../utils';
import {formatResponseValue, ensureW3cResponse} from '../protocol/helpers';
import http from 'node:http';
import https from 'node:https';
@@ -69,8 +69,11 @@ export class JWProxy {
private readonly _log: AppiumLogger | undefined;
constructor(opts: ProxyOptions = {}) {
const filteredOpts = _.pick(opts, ALLOWED_OPTS);
const options = _.defaults(_.omit(filteredOpts, 'log'), {
const filteredOptsWithoutLog = omit(
pick(opts as Record<string, unknown>, ALLOWED_OPTS),
'log'
) as Omit<ProxyOptions, 'log'>;
const options = {
scheme: 'http',
server: 'localhost',
port: 4444,
@@ -78,7 +81,8 @@ export class JWProxy {
reqBasePath: DEFAULT_BASE_PATH,
sessionId: null,
timeout: DEFAULT_REQUEST_TIMEOUT,
}) as ProxyOptions & {
...filteredOptsWithoutLog,
} as ProxyOptions & {
scheme: string;
server: string;
port: number;
@@ -153,7 +157,7 @@ export class JWProxy {
const requiresSessionId =
!commandName || (commandName && isSessionCommand(commandName));
const proxyPrefix = `${this.scheme}://${this.server}:${this.port}${this.base}`;
let proxySuffix = normalizedPathname ? `/${_.trimStart(normalizedPathname, '/')}` : '';
let proxySuffix = normalizedPathname ? `/${normalizedPathname.replace(/^\/+/, '')}` : '';
if (parsedUrl.search) {
proxySuffix += parsedUrl.search;
}
@@ -179,7 +183,7 @@ export class JWProxy {
method = method.toUpperCase();
const newUrl = this.getUrlForProxy(url, method as HTTPMethod);
const truncateBody = (content: unknown): string =>
_.truncate(_.isString(content) ? content : JSON.stringify(content), {
util.truncateString(typeof content === 'string' ? content : JSON.stringify(content), {
length: MAX_LOG_BODY_LENGTH,
});
const reqOpts: RawAxiosRequestConfig = {
@@ -242,7 +246,7 @@ export class JWProxy {
const {data, status, headers} = await this.request(reqOpts);
// `data` might be really big
// Be careful while handling it to avoid memory leaks
if (!_.isPlainObject(data)) {
if (!util.isPlainObject(data)) {
// The response should be a valid JSON object
// If it cannot be coerced to an object then the response is wrong
throwProxyError(data);
@@ -252,23 +256,15 @@ export class JWProxy {
const isSessionCreationRequest = url.endsWith('/session') && method === 'POST';
if (isSessionCreationRequest) {
if (status === 200) {
const value = (data as Record<string, unknown>).value as
| Record<string, unknown>
| undefined;
const raw =
(data as Record<string, unknown>).sessionId ?? value?.sessionId;
const value = data.value as Record<string, unknown> | undefined;
const raw = data.sessionId ?? value?.sessionId;
this.sessionId =
typeof raw === 'string' ? raw : raw != null ? String(raw) : null;
}
this.downstreamProtocol = this.getProtocolFromResBody(
data as Record<string, unknown>
) ?? this.downstreamProtocol;
this.downstreamProtocol = this.getProtocolFromResBody(data) ?? this.downstreamProtocol;
this.log.info(`Determined the downstream protocol as '${this.downstreamProtocol}'`);
}
if (
_.has(data, 'status') &&
parseInt((data as Record<string, unknown>).status as string, 10) !== 0
) {
if (Object.hasOwn(data, 'status') && parseInt(data.status as string, 10) !== 0) {
throwProxyError(data);
}
return [
@@ -307,10 +303,10 @@ export class JWProxy {
* Detects the downstream protocol from a response body.
*/
getProtocolFromResBody(resObj: Record<string, unknown>): Protocol | undefined {
if (_.isInteger(resObj.status)) {
if (Number.isInteger(resObj.status)) {
return MJSONWP;
}
if (!_.isUndefined(resObj.value)) {
if (resObj.value !== undefined) {
return W3C;
}
}
@@ -363,10 +359,10 @@ export class JWProxy {
const status = parseInt(resBody.status as string, 10);
if (!isNaN(status) && status !== 0) {
let message: unknown = resBody.value;
if (_.isPlainObject(message) && _.has(message, 'message')) {
if (util.isPlainObject(message) && Object.hasOwn(message, 'message')) {
message = (message as Record<string, unknown>).message;
}
throw errorFromMJSONWPStatusCode(status, _.isEmpty(message)
throw errorFromMJSONWPStatusCode(status, util.isEmpty(message)
? getSummaryByCode(status)
: (message as string | {message: string}));
}
@@ -374,8 +370,8 @@ export class JWProxy {
if (response.statusCode < 300) {
return resBody.value;
}
if (_.isPlainObject(resBody.value) && (resBody.value as Record<string, unknown>).error) {
const value = resBody.value as Record<string, unknown>;
if (util.isPlainObject(resBody.value) && resBody.value.error) {
const value = resBody.value;
throw errorFromW3CJsonCode(
value.error as string,
(value.message as string) ?? '',
@@ -387,7 +383,7 @@ export class JWProxy {
}
throw new errors.UnknownError(
`Did not know what to do with response code '${response.statusCode}' ` +
`and response body '${_.truncate(JSON.stringify(resBodyObj), {
`and response body '${util.truncateString(JSON.stringify(resBodyObj), {
length: 300,
})}'`
);
@@ -424,16 +420,16 @@ export class JWProxy {
);
}
res.setHeader('content-type', 'application/json; charset=utf-8');
if (!_.isPlainObject(resBodyObj)) {
if (!util.isPlainObject(resBodyObj)) {
const error = new errors.UnknownError(
`The downstream server response with the status code ${statusCode} is not a valid JSON object: ` +
_.truncate(`${resBodyObj}`, {length: 300})
util.truncateString(`${resBodyObj}`, {length: 300})
);
[statusCode, resBodyObj] = getResponseForW3CError(error);
}
const resBody = resBodyObj as Record<string, unknown>;
if (_.has(resBody, 'sessionId')) {
if (Object.hasOwn(resBody, 'sessionId')) {
const reqSessionId = this.getSessionIdFromUrl(req.originalUrl);
if (reqSessionId) {
this.log.info(`Replacing sessionId ${resBody.sessionId} with ${reqSessionId}`);
@@ -459,7 +455,10 @@ export class JWProxy {
try {
return await req.execute();
} finally {
_.pull(this._activeRequests, req);
const reqIndex = this._activeRequests.indexOf(req);
if (reqIndex >= 0) {
this._activeRequests.splice(reqIndex, 1);
}
}
}
@@ -467,8 +466,8 @@ export class JWProxy {
// eslint-disable-next-line n/no-deprecated-api -- we need relative URL support
const parsedUrl = nodeUrl.parse(url || '/');
if (
_.isNil(parsedUrl.href) ||
_.isNil(parsedUrl.pathname) ||
parsedUrl.href == null ||
parsedUrl.pathname == null ||
(parsedUrl.protocol && !['http:', 'https:'].includes(parsedUrl.protocol))
) {
throw new Error(`Did not know how to proxy the url '${url}'`);
@@ -477,7 +476,7 @@ export class JWProxy {
}
private _toNormalizedPathname(parsedUrl: nodeUrl.UrlWithStringQuery): string {
if (!_.isString(parsedUrl.pathname)) {
if (typeof parsedUrl.pathname !== 'string') {
return '';
}
let pathname =
@@ -488,20 +487,20 @@ export class JWProxy {
// This is needed for the backward compatibility
// if drivers don't set reqBasePath properly
if (!this.reqBasePath) {
if (match && match.params && _.isArray((match.params as Record<string, unknown>).prefix)) {
if (match && match.params && Array.isArray((match.params as Record<string, unknown>).prefix)) {
pathname = pathname.replace(
`/${((match.params as Record<string, unknown>).prefix as string[]).join('/')}`,
''
);
} else if (_.startsWith(pathname, '/wd/hub')) {
} else if (pathname.startsWith('/wd/hub')) {
pathname = pathname.replace('/wd/hub', '');
}
}
let result = pathname;
if (match && match.params) {
const command = (match.params as Record<string, unknown>).command;
result = _.isArray(command) ? `/${(command as string[]).join('/')}` : '';
result = Array.isArray(command) ? `/${(command as string[]).join('/')}` : '';
}
return _.trimEnd(result, '/');
return result.replace(/\/+$/, '');
}
}
+29 -28
View File
@@ -1,4 +1,3 @@
import _ from 'lodash';
import {util, logger} from '@appium/support';
import {StatusCodes as HTTPStatusCodes} from 'http-status-codes';
import type {ErrorBiDiCommandResponse, Class} from '@appium/types';
@@ -22,13 +21,13 @@ class BaseError extends Error {
private _formatStack(): void {
// eslint-disable-next-line no-prototype-builtins
if (Error.hasOwnProperty('captureStackTrace') && _.isEmpty(this.stack)) {
if (Error.hasOwnProperty('captureStackTrace') && util.isEmpty(this.stack)) {
Error.captureStackTrace(this, this.constructor);
}
if (!_.isString(this.cause?.stack)) {
if (typeof this.cause?.stack !== 'string') {
return;
}
if (_.isEmpty(this.stack)) {
if (util.isEmpty(this.stack)) {
this.stack = this.cause.stack;
return;
}
@@ -77,7 +76,7 @@ export class ProtocolError extends BaseError {
bidiErrObject(id: string | number): ErrorBiDiCommandResponse {
// if we don't have an id, the client didn't send one, so we have nothing to send back.
// send back zero rather than making something up
const intId = (_.isInteger(id) ? id : (parseInt(`${id}`, 10) || 0)) as number;
const intId = (Number.isInteger(id) ? id : (parseInt(`${id}`, 10) || 0)) as number;
return {
id: intId,
type: 'error',
@@ -957,30 +956,30 @@ export class ProxyRequestError extends BaseError {
) {
const [responseErrorObj, originalMessage] = ProxyRequestError._parseHttpResponse(httpResponseData);
super(
_.isEmpty(message)
util.isEmpty(message)
? `Proxy request unsuccessful.${originalMessage ? (' ' + originalMessage) : ''}`
: message,
cause,
);
// If the response error is an object and value is an object, it's a W3C error (for JSONWP value is a string)
if (_.isPlainObject(responseErrorObj.value) && _.has(responseErrorObj.value, 'error')) {
this._w3cError = responseErrorObj.value;
if (util.isPlainObject(responseErrorObj.value) && Object.hasOwn(responseErrorObj.value, 'error')) {
this._w3cError = responseErrorObj.value as unknown as typeof this._w3cError;
this._w3cErrorStatus = httpStatus;
} else if (_.has(responseErrorObj, 'status')) {
this._jwpError = responseErrorObj;
} else if (Object.hasOwn(responseErrorObj, 'status')) {
this._jwpError = responseErrorObj as typeof this._jwpError;
}
}
private static _parseHttpResponse(data: any): [Record<string, any>, string] {
let responseErrorObj: Record<string, any> = util.safeJsonParse(data);
if (!_.isPlainObject(responseErrorObj)) {
if (!util.isPlainObject(responseErrorObj)) {
responseErrorObj = {};
}
let errorMessage: string = _.isString(data) ? data : '';
if (_.isString(responseErrorObj.value)) {
let errorMessage: string = typeof data === 'string' ? data : '';
if (typeof responseErrorObj.value === 'string') {
errorMessage = responseErrorObj.value;
} else if (_.isString(responseErrorObj.value?.message)) {
} else if (typeof responseErrorObj.value?.message === 'string') {
errorMessage = responseErrorObj.value.message;
}
return [responseErrorObj, errorMessage];
@@ -991,7 +990,7 @@ export class ProxyRequestError extends BaseError {
// If it's MJSONWP error, returns actual error cause for request failure based on `jsonwp.status`
return errorFromMJSONWPStatusCode(this._jwpError.status, this._jwpError.value);
}
if (util.hasValue(this._w3cError) && _.isNumber(this._w3cErrorStatus) && this._w3cErrorStatus >= 300) {
if (util.hasValue(this._w3cError) && typeof this._w3cErrorStatus === 'number' && this._w3cErrorStatus >= 300) {
return errorFromW3CJsonCode(
this._w3cError.error,
this._w3cError.message || this.message,
@@ -1007,10 +1006,10 @@ function generateBadParametersMessage(
paramNames: string[]
): string {
const toArray = function <T> (x: T | T[]): T[] {
if (_.isUndefined(x)) {
if (x === undefined) {
return [];
}
if (_.isArray(x)) {
if (Array.isArray(x)) {
return x;
}
return [x];
@@ -1018,26 +1017,28 @@ function generateBadParametersMessage(
const requiredParamNames = toArray(paramRequirements.required);
const actualParamNames = toArray(paramNames);
const missingRequiredParamNames = _.difference(requiredParamNames, actualParamNames);
const missingRequiredParamNames = requiredParamNames.filter((name) => !actualParamNames.includes(name));
const resultLines: string[] = [];
resultLines.push(
_.isEmpty(missingRequiredParamNames)
util.isEmpty(missingRequiredParamNames)
? // This should not happen
'Some of the provided parameters are not known'
: `The following required parameter${
missingRequiredParamNames.length === 1 ? ' is' : 's are'
} missing: ${JSON.stringify(missingRequiredParamNames)}`,
);
if (!_.isEmpty(requiredParamNames)) {
if (!util.isEmpty(requiredParamNames)) {
resultLines.push(`Known required parameters are: ${JSON.stringify(requiredParamNames)}`);
}
const optionalParamNames = _.difference(toArray(paramRequirements.optional), ['sessionId', 'id']);
if (!_.isEmpty(optionalParamNames)) {
const optionalParamNames = toArray(paramRequirements.optional).filter(
(name): name is string => typeof name === 'string' && !['sessionId', 'id'].includes(name)
);
if (!util.isEmpty(optionalParamNames)) {
resultLines.push(`Known optional parameters are: ${JSON.stringify(optionalParamNames)}`);
}
resultLines.push(
`You have provided${
_.isEmpty(actualParamNames) ? ' none' : ': ' + JSON.stringify(paramNames)
util.isEmpty(actualParamNames) ? ' none' : ': ' + JSON.stringify(paramNames)
}`,
);
return resultLines.join('\n');
@@ -1087,7 +1088,7 @@ export const errors = {
ProxyRequestError,
} as const;
const jsonwpErrorCodeMap: Record<string, Class<ProtocolError>> = _.values(errors)
const jsonwpErrorCodeMap: Record<string, Class<ProtocolError>> = Object.values(errors)
.reduce((acc: Record<string, Class<ProtocolError>>, ErrorClass: any) => {
if ('code' in ErrorClass) {
acc[ErrorClass.code()] = ErrorClass;
@@ -1095,7 +1096,7 @@ const jsonwpErrorCodeMap: Record<string, Class<ProtocolError>> = _.values(errors
return acc;
}, {});
const w3cErrorCodeMap: Record<string, Class<ProtocolError>> = _.values(errors)
const w3cErrorCodeMap: Record<string, Class<ProtocolError>> = Object.values(errors)
.reduce((acc: Record<string, Class<ProtocolError>>, ErrorClass: any) => {
if ('error' in ErrorClass) {
acc[ErrorClass.error()] = ErrorClass;
@@ -1151,7 +1152,7 @@ export function errorFromMJSONWPStatusCode(code: number, value: string | {messag
* @return The error that is associated with the W3C error string
*/
export function errorFromW3CJsonCode(signature: string, message: string, stacktrace?: string): ProtocolError {
const ErrorClass = w3cErrorCodeMap[_.toLower(signature)] ?? UnknownError;
const ErrorClass = w3cErrorCodeMap[signature.toLowerCase()] ?? UnknownError;
w3cLog.debug(`Matched W3C error code '${signature}' to ${ErrorClass.name}`);
const resultError = new ErrorClass(message);
resultError.stacktrace = stacktrace;
@@ -1177,12 +1178,12 @@ export function getResponseForW3CError(err: any): [number, { value: W3CError }]
];
// err is ProtocolError
if (['error', 'w3cStatus'].every((prop) => _.has(err, prop))) {
if (['error', 'w3cStatus'].every((prop) => Object.hasOwn(err, prop))) {
return protocolErrorToResponse(err);
}
// err is ProxyRequestError
if (_.has(err, 'getActualError') && _.isFunction(err.getActualError)) {
if (Object.hasOwn(err, 'getActualError') && typeof err.getActualError === 'function') {
return protocolErrorToResponse(err.getActualError());
}
+9 -5
View File
@@ -1,4 +1,4 @@
import _ from 'lodash';
import {util} from '@appium/support';
import {duplicateKeys} from '../basedriver/helpers';
import {MJSONWP_ELEMENT_KEY, W3C_ELEMENT_KEY} from '../constants';
@@ -11,7 +11,7 @@ import {MJSONWP_ELEMENT_KEY, W3C_ELEMENT_KEY} from '../constants';
* @returns Either modified value or the same one if nothing has been modified
*/
export function formatResponseValue(resValue: object | undefined): object | null {
if (_.isUndefined(resValue)) {
if (resValue === undefined) {
// convert undefined to null
return null;
}
@@ -30,7 +30,11 @@ export function formatResponseValue(resValue: object | undefined): object | null
* @returns The fixed response body
*/
export function ensureW3cResponse(responseBody: Record<string, unknown>): Record<string, unknown> {
return _.isPlainObject(responseBody)
? (_.omit(responseBody, ['status', 'sessionId']) as Record<string, unknown>)
: responseBody;
if (!util.isPlainObject(responseBody)) {
return responseBody;
}
const result = {...responseBody};
delete result.status;
delete result.sessionId;
return result;
}
+44 -46
View File
@@ -1,4 +1,3 @@
import _ from 'lodash';
import {util, logger} from '@appium/support';
import {validators} from './validators';
import {
@@ -10,11 +9,11 @@ import {
BadParametersError,
} from './errors';
import {METHOD_MAP, NO_SESSION_ID_COMMANDS} from './routes';
import B from 'bluebird';
import {formatResponseValue, ensureW3cResponse} from './helpers';
import {MAX_LOG_BODY_LENGTH, PROTOCOLS, DEFAULT_BASE_PATH} from '../constants';
import {isW3cCaps} from '../helpers/capabilities';
import {log} from '../basedriver/logger';
import {omitKeys} from '../utils';
import {generateDriverLogPrefix} from '../basedriver/helpers';
import type {Core, AppiumLogger, PayloadParams, MethodMap, Driver, DriverMethodDef} from '@appium/types';
import type {BaseDriver} from '../basedriver/driver';
@@ -35,7 +34,7 @@ export const deprecatedCommandsLogged: Set<string> = new Set();
* @param createSessionArgs - Arguments passed to the createSession command
*/
export function determineProtocol(createSessionArgs: any[]): keyof typeof PROTOCOLS {
return _.some(createSessionArgs, isW3cCaps) ? PROTOCOLS.W3C : PROTOCOLS.MJSONWP;
return createSessionArgs.some(isW3cCaps) ? PROTOCOLS.W3C : PROTOCOLS.MJSONWP;
}
/**
@@ -72,7 +71,7 @@ export function getSessionId(driver: Core<any>, req: Request): string | undefine
* @returns Whether the command requires a session id in the URL
*/
export function isSessionCommand(command: string): boolean {
return !_.includes(NO_SESSION_ID_COMMANDS, command);
return !NO_SESSION_ID_COMMANDS.includes(command);
}
/**
@@ -88,12 +87,12 @@ export function checkParams(
): Record<string, any> {
let requiredParams: string[][] = [];
let optionalParams: string[] = [];
const actualParamNames: string[] = _.keys(args);
const actualParamNames: string[] = Object.keys(args);
if (paramSpec.required) {
// we might have an array of parameters,
// or an array of arrays of parameters, so standardize
requiredParams = _.cloneDeep(
requiredParams = structuredClone(
(hasMultipleRequiredParamSets(paramSpec.required)
? paramSpec.required
: [paramSpec.required]
@@ -102,7 +101,7 @@ export function checkParams(
}
// optional parameters are just an array
if (paramSpec.optional) {
optionalParams = _.cloneDeep(paramSpec.optional as string[]);
optionalParams = structuredClone(paramSpec.optional as string[]);
}
// If a function was provided as the 'validate' key, it will here be called with
@@ -112,41 +111,41 @@ export function checkParams(
if (paramSpec.validate) {
const message = paramSpec.validate(args, protocol ?? PROTOCOLS.W3C);
if (message) {
throw new errors.InvalidArgumentError(_.isString(message) ? message : undefined);
throw new errors.InvalidArgumentError(typeof message === 'string' ? message : undefined);
}
}
// some clients pass in the session id in the params
if (!_.includes(optionalParams, 'sessionId')) {
if (!optionalParams.includes('sessionId')) {
optionalParams.push('sessionId');
}
// some clients pass in an element id in the params
if (!_.includes(optionalParams, 'id')) {
if (!optionalParams.includes('id')) {
optionalParams.push('id');
}
if (_.isEmpty(requiredParams)) {
if (util.isEmpty(requiredParams)) {
// if we don't have any required parameters, then just filter out unknown ones
return pickKnownParams(args, _.difference(actualParamNames, optionalParams));
return pickKnownParams(args, actualParamNames.filter((name) => !optionalParams.includes(name)));
}
// go through the required parameters and check against our arguments
let matchedReqParamSet: string[] = [];
for (const requiredParamsSet of requiredParams) {
if (!_.isArray(requiredParamsSet)) {
if (!Array.isArray(requiredParamsSet)) {
throw new Error(
`The required parameter set item ${JSON.stringify(requiredParamsSet)} ` +
`in ${JSON.stringify(paramSpec)} is not an array. ` +
`This is a bug in the method map definition.`
);
}
if (_.isEmpty(_.difference(requiredParamsSet, actualParamNames))) {
if (requiredParamsSet.every((name) => actualParamNames.includes(name))) {
return pickKnownParams(
args,
_.difference(actualParamNames, requiredParamsSet, optionalParams)
actualParamNames.filter((name) => !requiredParamsSet.includes(name) && !optionalParams.includes(name))
);
}
if (!_.isEmpty(requiredParamsSet) && _.isEmpty(matchedReqParamSet)) {
if (!util.isEmpty(requiredParamsSet) && util.isEmpty(matchedReqParamSet)) {
matchedReqParamSet = requiredParamsSet;
}
}
@@ -168,7 +167,7 @@ export function makeArgs(requestParams: PayloadParams, jsonObj: any, payloadPara
// since the command will sometimes want to ignore, say, the sessionId.
// This has the effect of putting sessionId last, which means in JS we can
// omit it from the function signature if we're not going to use it.
const urlParams = _.keys(requestParams).reverse();
const urlParams = Object.keys(requestParams).reverse();
// In the simple case, the required parameters are a basic array in
// payloadParams.required, so start there. It's possible that there are
@@ -180,9 +179,9 @@ export function makeArgs(requestParams: PayloadParams, jsonObj: any, payloadPara
// array of arrays in payloadParams.required, so loop through each set and
// pick the one that matches which JSON params were actually sent. We've
// already been through validation so we're guaranteed to find a match.
const keys = _.keys(jsonObj);
const keys = Object.keys(jsonObj);
for (const params of payloadParams.required) {
if (_.without(params, ...keys).length === 0) {
if (params.filter((p) => !keys.includes(p)).length === 0) {
requiredParams = params;
break;
}
@@ -191,7 +190,7 @@ export function makeArgs(requestParams: PayloadParams, jsonObj: any, payloadPara
// Now we construct our list of arguments which will be passed to the command
let args;
if (_.isFunction(payloadParams.makeArgs)) {
if (typeof payloadParams.makeArgs === 'function') {
// In the route spec, a particular route might define a 'makeArgs' function
// if it wants full control over how to turn JSON parameters into command
// arguments. So we pass it the JSON parameters and it returns an array
@@ -202,9 +201,9 @@ export function makeArgs(requestParams: PayloadParams, jsonObj: any, payloadPara
} else {
// Otherwise, collect all the required and optional params and flatten them
// into an argument array
args = _.flatten(requiredParams).map((p) => jsonObj[p]);
args = (requiredParams ?? []).flat().map((p) => jsonObj[p]);
if (payloadParams.optional) {
args = args.concat(_.flatten(payloadParams.optional).map((p) => jsonObj[p]));
args = args.concat((payloadParams.optional ?? []).flat().map((p) => jsonObj[p]));
}
}
// Finally, get our url params (session id, element id, etc...) on the end of
@@ -222,14 +221,14 @@ export function validateExecuteMethodParams(params: any[], paramSpec?: PayloadPa
// the w3c protocol will give us an array of arguments to apply to a javascript function.
// that's not what we're doing. we're going to look for a JS object as the first arg, so we
// can perform validation on it. we'll ignore everything else.
if (!params || !_.isArray(params) || params.length > 1) {
if (!params || !Array.isArray(params) || params.length > 1) {
throw new errors.InvalidArgumentError(
`Did not get correct format of arguments for execute method. Expected zero or one ` +
`arguments to execute script and instead received: ${JSON.stringify(params)}`
);
}
const args: Record<string, any> = params[0] ?? {};
if (!_.isPlainObject(args)) {
if (!util.isPlainObject(args)) {
throw new errors.InvalidArgumentError(
`Did not receive an appropriate execute method parameters object. It needs to be ` +
`deserializable as a plain JS object`
@@ -265,8 +264,8 @@ export function routeConfiguringFunction(driver: Core<any>): RouteConfiguringFun
driver.basePath = basePath;
const allMethods: MethodMap<Driver> = {...METHOD_MAP, ...extraMethodMap};
for (const [path, methods] of _.toPairs(allMethods)) {
for (const [method, spec] of _.toPairs(methods)) {
for (const [path, methods] of Object.entries(allMethods)) {
for (const [method, spec] of Object.entries(methods)) {
const isSessCommand = spec.command ? isSessionCommand(spec.command) : false;
// set up the express route handler
buildHandler(
@@ -311,7 +310,7 @@ export function driverShouldDoJwpProxy(driver: Core<any>, req: Request, command:
}
function extractProtocol(driver: Core<any>, sessionId: string | null = null): keyof typeof PROTOCOLS {
const dstDriver = _.isFunction(driver.driverForSession) && sessionId
const dstDriver = typeof driver.driverForSession === 'function' && sessionId
? driver.driverForSession(sessionId)
: driver;
if (dstDriver === driver) {
@@ -327,10 +326,10 @@ function extractProtocol(driver: Core<any>, sessionId: string | null = null): ke
function getLogger(driver: Core<any>, sessionId: string | null = null): AppiumLogger {
const dstDriver =
sessionId && _.isFunction(driver.driverForSession)
sessionId && typeof driver.driverForSession === 'function'
? driver.driverForSession(sessionId) ?? driver
: driver;
if (_.isFunction(dstDriver.log?.info)) {
if (typeof dstDriver.log?.info === 'function') {
return dstDriver.log;
}
@@ -345,7 +344,7 @@ function wrapParams<T>(paramSets, jsonObj: T): T | Record<string, T> {
* The wrap option in the spec enforce wrapping before validation, so that all params are wrapped at
* the time they are validated and later passed to the commands.
*/
return (_.isArray(jsonObj) || !_.isObject(jsonObj)) && paramSets.wrap
return (Array.isArray(jsonObj) || typeof jsonObj !== 'object' || jsonObj === null) && paramSets.wrap
? {[paramSets.wrap]: jsonObj}
: jsonObj;
}
@@ -354,7 +353,7 @@ function unwrapParams<T>(paramSets: PayloadParams, jsonObj: T): T | Record<strin
/* There are commands like setNetworkConnection which send parameters wrapped inside a key such as
* "parameters". This function unwraps them (eg. {"parameters": {"type": 1}} becomes {"type": 1}).
*/
return _.isObject(jsonObj) && paramSets.unwrap && jsonObj[paramSets.unwrap]
return typeof jsonObj === 'object' && jsonObj !== null && paramSets.unwrap && jsonObj[paramSets.unwrap]
? jsonObj[paramSets.unwrap]
: jsonObj;
}
@@ -363,16 +362,15 @@ function unwrapParams<T>(paramSets: PayloadParams, jsonObj: T): T | Record<strin
function hasMultipleRequiredParamSets(
required: ReadonlyArray<string> | MultidimensionalReadonlyArray<string, 2> | undefined
): required is MultidimensionalReadonlyArray<string, 2> {
//@ts-expect-error Needed to convince lodash typechecks
return Boolean(required && _.isArray(_.first(required)));
return Boolean(required && Array.isArray(required?.[0]));
}
function pickKnownParams(args: Record<string, any>, unknownNames: string[]): Record<string, any> {
if (_.isEmpty(unknownNames)) {
if (util.isEmpty(unknownNames)) {
return args;
}
log.info(`The following arguments are not known and will be ignored: ${unknownNames}`);
return _.pickBy(args, (v, k) => !unknownNames.includes(k));
return omitKeys(args, unknownNames);
}
function buildHandler(
@@ -419,7 +417,7 @@ function buildHandler(
let didPluginOverrideProxy = false;
if (isSessCmd && !spec.neverProxy && spec.command && driverShouldDoJwpProxy(driver, req, spec.command)) {
if (
!('pluginsToHandleCmd' in driver) || !_.isFunction(driver.pluginsToHandleCmd) ||
!('pluginsToHandleCmd' in driver) || typeof driver.pluginsToHandleCmd !== 'function' ||
driver.pluginsToHandleCmd(spec.command, sessionId).length === 0
) {
await doJwpProxy(driver as BaseDriver<any>, req, res);
@@ -475,7 +473,7 @@ function buildHandler(
getLogger(driver, sessionId).debug(
`Calling %s.%s() with args: %s`,
driver.constructor.name, spec.command,
logger.markSensitive(_.truncate(JSON.stringify(args), {length: MAX_LOG_BODY_LENGTH}))
logger.markSensitive(util.truncateString(JSON.stringify(args), {length: MAX_LOG_BODY_LENGTH}))
);
if (didPluginOverrideProxy) {
@@ -492,8 +490,8 @@ function buildHandler(
// If `executeCommand` was overridden and the method returns an object
// with a protocol and value/error property, re-assign the protocol
if (_.isPlainObject(driverRes) && _.has(driverRes, 'protocol')) {
currentProtocol = driverRes.protocol || currentProtocol;
if (util.isPlainObject(driverRes) && Object.hasOwn(driverRes, 'protocol')) {
currentProtocol = (driverRes as {protocol?: keyof typeof PROTOCOLS}).protocol || currentProtocol;
if (driverRes.error) {
throw driverRes.error;
}
@@ -520,7 +518,7 @@ function buildHandler(
// delete should not return anything even if successful
if (spec.command === DELETE_SESSION_COMMAND) {
getLogger(driver, sessionId).debug(
`Received response: ${_.truncate(JSON.stringify(driverRes), {
`Received response: ${util.truncateString(JSON.stringify(driverRes), {
length: MAX_LOG_BODY_LENGTH,
})}`
);
@@ -536,7 +534,7 @@ function buildHandler(
parseInt(driverRes.status, 10) !== 0
) {
throw errorFromMJSONWPStatusCode(driverRes.status, driverRes.value);
} else if (_.isPlainObject(driverRes.value) && driverRes.value.error) {
} else if (util.isPlainObject(driverRes.value) && driverRes.value.error) {
throw errorFromW3CJsonCode(
driverRes.value.error,
driverRes.value.message,
@@ -548,7 +546,7 @@ function buildHandler(
httpResBody.value = driverRes;
getLogger(driver, sessionId || newSessionId).debug(
`Responding ` +
`to client with driver.${spec.command}() result: ${_.truncate(JSON.stringify(driverRes), {
`to client with driver.${spec.command}() result: ${util.truncateString(JSON.stringify(driverRes), {
length: MAX_LOG_BODY_LENGTH,
})}`
);
@@ -556,7 +554,7 @@ function buildHandler(
// if anything goes wrong, figure out what our response should be
// based on the type of error that we encountered
let actualErr;
if (err instanceof Error || (_.has(err, 'stack') && _.has(err, 'message'))) {
if (err instanceof Error || (Object.hasOwn(err, 'stack') && Object.hasOwn(err, 'message'))) {
actualErr = err;
} else {
getLogger(driver, sessionId || newSessionId).warn(
@@ -570,7 +568,7 @@ function buildHandler(
currentProtocol || extractProtocol(driver, sessionId || newSessionId);
let errMsg = err.stacktrace || err.stack;
if (!_.includes(errMsg, err.message)) {
if (!errMsg.includes(err.message)) {
// if the message has more information, add it. but often the message
// is the first part of the stack trace
errMsg = `${err.message}${errMsg ? '\n' + errMsg : ''}`;
@@ -587,7 +585,7 @@ function buildHandler(
}
// decode the response, which is either a string or json
if (_.isString(httpResBody)) {
if (typeof httpResBody === 'string') {
res.status(httpStatus)
.setHeader('content-type', 'application/json; charset=utf-8')
.send(httpResBody);
@@ -600,7 +598,7 @@ function buildHandler(
};
// add the method to the app
app[method.toLowerCase()](path, (req, res) => {
B.resolve(asyncHandler(req, res)).done();
void asyncHandler(req, res);
});
}
+9 -9
View File
@@ -1,5 +1,5 @@
import type {Driver, DriverMethodDef, HTTPMethod, MethodMap} from '@appium/types';
import _ from 'lodash';
import {util} from '@appium/support';
import {DEFAULT_BASE_PATH} from '../constants';
import {match} from 'path-to-regexp';
import {LRUCache} from 'lru-cache';
@@ -579,9 +579,9 @@ export const METHOD_MAP = {
} as const satisfies MethodMap<Driver>;
// driver command names
export const ALL_COMMANDS = _.flatMap(_.values(METHOD_MAP).map(_.values))
.filter((m) => Boolean(m.command))
.map((m) => m.command);
export const ALL_COMMANDS = Object.values(METHOD_MAP)
.flatMap((methods) => Object.values(methods) as Array<{command?: string}>)
.flatMap((m) => (m.command ? [m.command] : []));
/**
* Resolve a WebDriver URL path and HTTP method to a driver command name from {@link METHOD_MAP}.
@@ -596,9 +596,9 @@ export function routeToCommandName(
): string | undefined {
const resolvedBasePath = basePath ?? DEFAULT_BASE_PATH;
let normalizedEndpoint = resolvedBasePath
? endpoint.replace(new RegExp(`^${_.escapeRegExp(resolvedBasePath)}`), '')
? endpoint.replace(new RegExp(`^${util.escapeRegExp(resolvedBasePath)}`), '')
: endpoint;
normalizedEndpoint = `${_.startsWith(normalizedEndpoint, '/') ? '' : '/'}${normalizedEndpoint}`;
normalizedEndpoint = `${normalizedEndpoint.startsWith('/') ? '' : '/'}${normalizedEndpoint}`;
let normalizedPathname: string;
try {
// we could use any prefix there as we anyway need to only extract the pathname
@@ -608,7 +608,7 @@ export function routeToCommandName(
throw new Error(`'${endpoint}' cannot be translated to a command name: ${msg}`, {cause: err});
}
const normalizedMethod = _.toUpper(method ?? '');
const normalizedMethod = (method ?? '').toUpperCase();
const cacheKey = toCommandNameCacheKey(normalizedPathname, normalizedMethod);
const cached = COMMAND_NAMES_CACHE.get(cacheKey);
if (cached !== undefined) {
@@ -620,12 +620,12 @@ export function routeToCommandName(
possiblePathnames.push(`/session/any-session-id${normalizedPathname}`);
}
possiblePathnames.push(normalizedPathname);
for (const [routePath, routeSpec] of _.toPairs(METHOD_MAP)) {
for (const [routePath, routeSpec] of Object.entries(METHOD_MAP)) {
const routeMatcher = match(routePath);
if (possiblePathnames.some((pp) => routeMatcher(pp))) {
const spec = routeSpec as Record<string, DriverMethodDef<Driver>>;
const commandForAnyMethod = () =>
_.first(_.keys(spec).map((key) => spec[key]?.command));
Object.keys(spec).map((key) => spec[key]?.command)[0];
const commandName = normalizedMethod ? spec[normalizedMethod]?.command : commandForAnyMethod();
if (commandName) {
COMMAND_NAMES_CACHE.set(cacheKey, commandName);
@@ -1,5 +1,3 @@
import _ from 'lodash';
export const validators = {
setUrl: (url: any) => {
// either an `xyz://`, `about:`, or `data:` scheme is allowed
@@ -15,5 +13,5 @@ export const validators = {
};
function isNumber(o: any): o is number {
return _.isNumber(o) || !_.isNaN(parseInt(o, 10)) || !_.isNaN(parseFloat(o));
return typeof o === 'number' || !Number.isNaN(parseInt(o, 10)) || !Number.isNaN(parseFloat(o));
}
+85
View File
@@ -0,0 +1,85 @@
import {util} from '@appium/support';
/**
* Deep-merge plain objects into a clone of `target`. Skips null/undefined sources.
* Non-plain values on a key replace the previous value (same as lodash merge for objects).
*/
export function mergePlainObjects<T extends Record<string, unknown>>(
target: T,
...sources: Array<Partial<T> | undefined>
): T {
const result = structuredClone(target);
for (const source of sources) {
if (source == null) {
continue;
}
for (const [key, value] of Object.entries(source)) {
const existing = result[key as keyof T];
if (util.isPlainObject(existing) && util.isPlainObject(value)) {
result[key as keyof T] = mergePlainObjects(
existing as Record<string, unknown>,
value as Record<string, unknown>,
) as T[keyof T];
} else if (value !== undefined) {
result[key as keyof T] = value as T[keyof T];
}
}
}
return result;
}
/** Return a shallow copy of `obj` without `key`. Non-objects are returned unchanged. */
export function omit<T extends Record<string, unknown>>(obj: T, key: string): T {
if (!util.isPlainObject(obj)) {
return obj;
}
return Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key)) as T;
}
/** Return a shallow copy of `obj` without any of `keys`. */
export function omitKeys<T extends Record<string, unknown>>(obj: T, keys: readonly string[]): T {
if (!util.isPlainObject(obj) || keys.length === 0) {
return obj;
}
const keysToOmit = new Set(keys);
return Object.fromEntries(Object.entries(obj).filter(([k]) => !keysToOmit.has(k))) as T;
}
/** Return a shallow copy of `obj` containing only listed keys. */
export function pick<T extends Record<string, unknown>>(
obj: T,
keys: readonly string[]
): Partial<T> {
const keysToPick = new Set(keys);
return Object.fromEntries(Object.entries(obj).filter(([k]) => keysToPick.has(k))) as Partial<T>;
}
/** Return a shallow copy of `obj` whose entries pass `predicate`. */
export function pickBy<T extends Record<string, unknown>>(
obj: T,
predicate: (value: T[keyof T], key: keyof T) => boolean
): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([key, value]) =>
predicate(value as T[keyof T], key as keyof T)
)
) as Partial<T>;
}
/** Compile a lodash-style template string (`<%= expression %>`) into a render function. */
export function compileLodashTemplate(
template: string
): (params: Record<string, unknown>) => string {
const parts: string[] = [];
let lastIndex = 0;
const re = /<%=\s*([\s\S]+?)\s*%>/g;
let match;
while ((match = re.exec(template)) !== null) {
parts.push(JSON.stringify(template.slice(lastIndex, match.index)));
parts.push(`String(${match[1]})`);
lastIndex = match.index + match[0].length;
}
parts.push(JSON.stringify(template.slice(lastIndex)));
const fn = new Function('obj', `with (obj) { return ${parts.join(' + ')}; }`);
return (params) => fn(params) as string;
}
+6 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@appium/base-driver",
"version": "10.5.0",
"version": "10.6.0",
"description": "Base driver class for Appium drivers",
"keywords": [
"automation",
@@ -45,19 +45,17 @@
"test:types": "tsd"
},
"dependencies": {
"@appium/support": "7.2.0",
"@appium/types": "1.4.0",
"@appium/support": "7.2.3",
"@appium/types": "1.5.0",
"@colors/colors": "1.6.0",
"async-lock": "1.4.1",
"asyncbox": "6.2.0",
"axios": "1.16.0",
"bluebird": "3.7.2",
"asyncbox": "6.3.0",
"axios": "1.16.1",
"body-parser": "2.2.2",
"express": "5.2.1",
"fastest-levenshtein": "1.0.16",
"http-status-codes": "2.3.0",
"lodash": "4.18.1",
"lru-cache": "11.3.5",
"lru-cache": "11.5.0",
"method-override": "3.0.0",
"morgan": "1.10.1",
"path-to-regexp": "8.4.2",
@@ -1,6 +1,5 @@
import chai, {expect} from 'chai';
import chaiAsPromised from 'chai-as-promised';
import _ from 'lodash';
import {server, routeConfiguringFunction, DEFAULT_WS_PATHNAME_PREFIX} from '../../../lib';
import {FakeDriver} from '../protocol/fake-driver';
import WebSocket from 'ws';
@@ -42,7 +41,7 @@ describe('Websockets (e2e)', function () {
const endpoint = `${DEFAULT_WS_PATHNAME_PREFIX}/hello`;
const timeout = 5000;
await baseServer.addWebSocketHandler(endpoint, wss);
expect(_.keys(await baseServer.getWebSocketHandlers()).length).to.eql(1);
expect(Object.keys(await baseServer.getWebSocketHandlers()).length).to.eql(1);
await new Promise<void>((resolve, reject) => {
const client = new WebSocket(`ws://${TEST_HOST}:${port}${endpoint}`);
client.once('upgrade', (res) => {
@@ -53,7 +52,7 @@ describe('Websockets (e2e)', function () {
}
});
client.once('message', (data) => {
const dataStr = _.isString(data) ? data : data.toString();
const dataStr = typeof data === 'string' ? data : data.toString();
expect(dataStr).to.eql(WS_DATA);
resolve();
});
@@ -65,7 +64,7 @@ describe('Websockets (e2e)', function () {
});
expect(await baseServer.removeWebSocketHandler(endpoint)).to.be.true;
expect(_.keys(await baseServer.getWebSocketHandlers()).length).to.eql(0);
expect(Object.keys(await baseServer.getWebSocketHandlers()).length).to.eql(0);
await new Promise<void>((resolve, reject) => {
const client = new WebSocket(`ws://${TEST_HOST}:${port}${endpoint}`);
client.on('message', (data) =>
@@ -6,7 +6,6 @@ import {server} from '../../../lib';
import axios from 'axios';
import {createSandbox} from 'sinon';
import {sleep} from 'asyncbox';
import _ from 'lodash';
import {exec} from 'teen_process';
import https from 'node:https';
import {TEST_HOST, getTestPort} from '@appium/driver-test-support';
@@ -110,14 +109,14 @@ describe('server', function () {
this.timeout(60000);
await expect(
server({
routeConfiguringFunction: _.noop,
routeConfiguringFunction: () => {},
port,
hostname: 'lolcathost',
})
).to.be.rejectedWith(/ENOTFOUND|EADDRNOTAVAIL|EAI_AGAIN/);
await expect(
server({
routeConfiguringFunction: _.noop,
routeConfiguringFunction: () => {},
port,
hostname: '1.1.1.1',
})
@@ -214,7 +213,7 @@ describe('server plugins', function () {
it('should allow one or more plugins to update the server', async function () {
hwServer = (await server({
routeConfiguringFunction: _.noop,
routeConfiguringFunction: () => {},
port,
serverUpdaters: [
updaterWithGetRoute('plugin1', 'res from plugin1 route'),
@@ -231,7 +230,7 @@ describe('server plugins', function () {
it('should pass on errors from the plugin updateServer method', async function () {
await expect(
server({
routeConfiguringFunction: _.noop,
routeConfiguringFunction: () => {},
port,
serverUpdaters: [
() => {
@@ -11,7 +11,6 @@ import {
promoteAppiumOptions,
stripAppiumPrefixes,
} from '../../../lib/basedriver/capabilities';
import _ from 'lodash';
import type {Capabilities, Constraints, W3CCapabilities} from '@appium/types';
import {BASE_DESIRED_CAP_CONSTRAINTS} from '@appium/types';
import {isW3cCaps} from '../../../lib/helpers/capabilities';
@@ -430,7 +429,7 @@ describe('caps', function () {
it('should validate when alwaysMatch and firstMatch[0] have the proper caps when merged together', function () {
caps = {
alwaysMatch: _.omit(matchingCaps, ['appium:deviceName']),
alwaysMatch: Object.fromEntries(Object.entries(matchingCaps).filter(([key]) => key !== 'appium:deviceName')),
firstMatch: [{'appium:deviceName': 'Fake'}],
};
expect(processCapabilities(caps as TestW3CCaps, constraints)).to.deep.equal(expectedMatchingCaps);
@@ -438,10 +437,10 @@ describe('caps', function () {
it('should validate when automationName is omitted', function () {
caps = {
alwaysMatch: _.omit(matchingCaps, ['appium:automationName']),
alwaysMatch: Object.fromEntries(Object.entries(matchingCaps).filter(([key]) => key !== 'appium:automationName')),
};
expect(processCapabilities(caps as TestW3CCaps, constraints)).to.deep.equal(
_.omit(expectedMatchingCaps, 'automationName')
Object.fromEntries(Object.entries(expectedMatchingCaps).filter(([key]) => key !== 'automationName'))
);
});
@@ -569,9 +568,8 @@ describe('caps', function () {
app: '/foo/bar.app.zip',
automationName: 'XCUITest',
};
const appiumCaps = _.mapKeys(
nonPrefixedAppiumCaps,
(value, key) => `${APPIUM_VENDOR_PREFIX}${key}`
const appiumCaps = Object.fromEntries(
Object.entries(nonPrefixedAppiumCaps).map(([key, value]) => [`${APPIUM_VENDOR_PREFIX}${key}`, value])
);
const standardCaps = {
platformName: 'iOS',
@@ -1,7 +1,6 @@
import chai, {expect} from 'chai';
import chaiAsPromised from 'chai-as-promised';
import type {InitialOpts} from '@appium/types';
import _ from 'lodash';
import {BaseDriver} from '../../../../lib';
chai.use(chaiAsPromised);
@@ -11,7 +10,7 @@ describe('logging custom events', function () {
const d = new BaseDriver({} as InitialOpts);
expect((d as any)._eventHistory).to.eql({commands: []});
await d.logCustomEvent('myorg', 'myevent');
expect(_.keys((d as any)._eventHistory)).to.eql(['commands', 'myorg:myevent']);
expect(Object.keys((d as any)._eventHistory)).to.eql(['commands', 'myorg:myevent']);
});
it('should get all events including custom ones', async function () {
const d = new BaseDriver({} as InitialOpts);
@@ -19,7 +18,7 @@ describe('logging custom events', function () {
d.logEvent('appiumEvent');
await d.logCustomEvent('myorg', 'myevent');
const events = await d.getLogEvents();
expect(_.keys(events)).to.eql(['commands', 'appiumEvent', 'myorg:myevent']);
expect(Object.keys(events)).to.eql(['commands', 'appiumEvent', 'myorg:myevent']);
});
});
@@ -1,7 +1,6 @@
import {expect} from 'chai';
import type {InitialOpts} from '@appium/types';
import {createSandbox} from 'sinon';
import _ from 'lodash';
import {BaseDriver} from '../../../../lib';
const FIRST_LOGS = ['first', 'logs'];
@@ -9,11 +8,11 @@ const SECOND_LOGS = ['second', 'logs'];
const SUPPORTED_LOG_TYPES = {
one: {
description: 'First logs',
getter: () => _.clone(FIRST_LOGS),
getter: () => structuredClone(FIRST_LOGS),
},
two: {
description: 'Seconds logs',
getter: () => _.clone(SECOND_LOGS),
getter: () => structuredClone(SECOND_LOGS),
},
};
@@ -26,7 +25,7 @@ describe('log commands -', function () {
driver = new BaseDriver({} as InitialOpts);
driver.supportedLogTypes = {};
(driver as any)._log = {
debug: _.noop,
debug: () => {},
} as any;
});
@@ -0,0 +1,347 @@
import {sleep} from 'asyncbox';
import chai, {expect} from 'chai';
import chaiAsPromised from 'chai-as-promised';
import {AppiumIpc, EVT_MESSAGE} from '../../../lib/basedriver/ipc';
import type {IpcMessage} from '@appium/types';
chai.use(chaiAsPromised);
describe('AppiumIpc', function () {
describe('subscriptionExists', function () {
it('should return false when no topic exists', function () {
const ipc = new AppiumIpc();
expect(ipc.subscriptionExists('foo', 'bar')).to.eql(false);
});
it('should return false when topic exists but no subscriber', function () {
const ipc = new AppiumIpc();
ipc.subscribe('foo', 'bar');
expect(ipc.subscriptionExists('foo', 'baz')).to.eql(false);
});
it('should return true when subscriber is subscribed', function () {
const ipc = new AppiumIpc();
ipc.subscribe('foo', 'bar');
expect(ipc.subscriptionExists('foo', 'bar')).to.eql(true);
});
});
describe('subscribe', function () {
it('should throw if subscription already exists', function () {
const ipc = new AppiumIpc();
ipc.subscribe('foo', 'bar');
expect(() => ipc.subscribe('foo', 'bar')).to.throw;
});
it('should throw when subscribing to a new topic beyond the limit', function () {
const ipc = new AppiumIpc({maxTopics: 2});
ipc.subscribe('topic1', 'sub1');
ipc.subscribe('topic2', 'sub2');
expect(() => ipc.subscribe('topic3', 'sub3')).to.throw(/2/);
});
it('should allow multiple subscribers on the same topic without counting extra topics', function () {
const ipc = new AppiumIpc({maxTopics: 2});
ipc.subscribe('topic1', 'sub1');
ipc.subscribe('topic1', 'sub2');
ipc.subscribe('topic2', 'sub3');
expect(() => ipc.subscribe('topic3', 'sub4')).to.throw(/2/);
});
it('should mention --max-ipc-topics in the error when the topic limit is reached', function () {
const ipc = new AppiumIpc({maxTopics: 1});
ipc.subscribe('topic1', 'sub1');
expect(() => ipc.subscribe('topic2', 'sub2')).to.throw(
/Cannot create new IPC topic 'topic2': maximum of 1 topics per session reached\. Adjust with the --max-ipc-topics server arg\./
);
});
});
describe('unsubscribe', function () {
it('should remove from list of subscriptions', function () {
const ipc = new AppiumIpc();
ipc.subscribe('foo', 'bar');
expect(ipc.subscriptionExists('foo', 'bar')).to.be.true;
ipc.unsubscribe('foo', 'bar');
expect(ipc.subscriptionExists('foo', 'bar')).to.be.false;
});
it('should unsubscribe via subscription object', function () {
const ipc = new AppiumIpc();
const sub = ipc.subscribe('foo', 'bar');
expect(ipc.subscriptionExists('foo', 'bar')).to.be.true;
sub.unsubscribe();
expect(ipc.subscriptionExists('foo', 'bar')).to.be.false;
});
});
describe('publish', function () {
it('should publish messages to all subscribers', async function () {
const ipc = new AppiumIpc();
const payload = {some: {cool: 'obj'}};
let sub1Res: IpcMessage<typeof payload>;
let sub2Res: IpcMessage<typeof payload>;
const sub1 = ipc.subscribe('foo', 'bar');
const sub2 = ipc.subscribe('foo', 'baz');
sub1.on(EVT_MESSAGE, (message) => sub1Res = message);
sub2.on(EVT_MESSAGE, (message) => sub2Res = message);
await ipc.publish('foo', 'zowee', payload);
expect(sub1Res!.data).to.eql(payload);
expect(sub2Res!.data).to.eql(payload);
expect(sub1Res!.publisher).to.eql('zowee');
expect(sub2Res!.publisher).to.eql('zowee');
expect(sub1Res!.topic).to.eql('foo');
expect(sub2Res!.topic).to.eql('foo');
expect(sub1Res!).to.have.property('timestampMs');
expect(sub2Res!).to.have.property('timestampMs');
});
it('should not publish messages to the publisher', async function () {
const ipc = new AppiumIpc();
const payload = {some: {cool: 'obj'}};
let sub1Res: IpcMessage<typeof payload>;
let sub2Res: IpcMessage<typeof payload>;
const sub1 = ipc.subscribe('foo', 'bar');
const sub2 = ipc.subscribe('foo', 'baz');
sub1.on(EVT_MESSAGE, (message) => sub1Res = message);
sub2.on(EVT_MESSAGE, (message) => sub2Res = message);
await ipc.publish('foo', 'baz', payload);
expect(sub2Res!).to.not.exist;
expect(sub1Res!.data).to.eql(payload);
expect(sub1Res!.publisher).to.eql('baz');
});
it('should publish using subscription object', async function () {
const ipc = new AppiumIpc();
const payload = {some: {cool: 'obj'}};
let sub1Res: IpcMessage<typeof payload>;
const sub1 = ipc.subscribe('foo', 'bar');
const sub2 = ipc.subscribe('foo', 'baz');
sub1.on(EVT_MESSAGE, (message) => sub1Res = message);
await sub2.publish(payload);
expect(sub1Res!.data).to.eql(payload);
expect(sub1Res!.publisher).to.eql('baz');
expect(sub1Res!.topic).to.eql('foo');
expect(sub1Res!).to.have.property('timestampMs');
});
it('should not publish to unsubscribed subscribers', async function () {
const ipc = new AppiumIpc();
const sub1 = ipc.subscribe<boolean>('foo', 'bar');
let sub1Res: boolean = false;
sub1.on(EVT_MESSAGE, (message) => sub1Res = message.data);
sub1.unsubscribe();
await ipc.publish<boolean>('foo', 'baz', true);
expect(sub1Res).to.be.false;
});
it('should not allow publish from subscription object after unsubscribing', function () {
const ipc = new AppiumIpc();
const sub1 = ipc.subscribe<boolean>('foo', 'bar');
sub1.unsubscribe();
expect(sub1.publish(true)).to.eventually.throw();
});
it('should be no race conditions with publishing and unsubscribing', async function () {
const ipc = new AppiumIpc();
const payload = {some: {cool: 'obj'}};
let rcvdCount = 0;
const sub1 = ipc.subscribe<typeof payload>('foo', 'bar',);
sub1.on(EVT_MESSAGE, () => {
rcvdCount++;
});
const promises = [];
promises.push(ipc.publish('foo', 'baz', payload)); // intentionally call without awaiting
sub1.unsubscribe(); // intentionally call without awaiting
promises.push(ipc.publish('foo', 'baz', payload)); // intentionally call without awaiting
// now that we've fired off a couple publishes, let's unsubscribe
await sleep(10);
expect(rcvdCount).to.eql(1); // not 0 or 2
await Promise.all(promises);
});
it('should allow running ipc commands in publish event callback', async function () {
const ipc = new AppiumIpc();
const payload = {some: {cool: 'obj'}};
let sub1Res: IpcMessage<typeof payload>;
let sub1Res2: IpcMessage<typeof payload> | undefined;
const sub1 = ipc.subscribe('foo', 'bar',);
sub1.on(EVT_MESSAGE, async (message) => {
sub1Res = message;
sub1Res2 = ipc.getMessage<typeof payload>('foo');
});
await ipc.publish('foo', 'baz', payload);
await sleep(0); // just spin once to let the publish callback do its thing
expect(sub1Res!.data).to.eql(payload);
expect(sub1Res!.publisher).to.eql('baz');
expect(sub1Res2!.data).to.eql(payload);
expect(sub1Res2!.publisher).to.eql('baz');
});
it('should throw an error when trying to publish a large message', async function () {
const ipc = new AppiumIpc({maxObjSize: 20}); // very small message size
const payload1 = 'hi'; // not so many bytes
const payload2 = 'helloworld!'.repeat(100); // lotsa bytes
await expect(ipc.publish('foo', 'bar', payload1)).to.eventually.eql(undefined);
await expect(ipc.publish('foo', 'bar', payload2)).to.eventually.be.rejectedWith(/20/);
});
it('should throw an error when publishing to a new topic beyond the limit', async function () {
const ipc = new AppiumIpc({maxTopics: 2});
await ipc.publish('topic1', 'pub', true);
await ipc.publish('topic2', 'pub', true);
await expect(ipc.publish('topic3', 'pub', true)).to.eventually.be.rejectedWith(/2/);
});
it('should count publish-only topics toward the limit without a prior subscribe', async function () {
const ipc = new AppiumIpc({maxTopics: 2});
await ipc.publish('topic1', 'pub', true);
ipc.subscribe('topic2', 'sub');
await expect(ipc.publish('topic3', 'pub', true)).to.eventually.be.rejectedWith(
/--max-ipc-topics/
);
});
it('should not allow sharing actual published object, only copies', async function () {
const ipc = new AppiumIpc();
const payload = {foo: 'bar'};
type MsgType = typeof payload;
const sub1 = ipc.subscribe<MsgType>('foo', 'sub1');
const sub2 = ipc.subscribe<MsgType>('foo', 'sub2');
sub1.on('message', ({data}) => {
expect(data).to.eql({foo: 'bar'});
data.foo = 'bad';
});
sub2.on('message', ({data}) => {
expect(data).to.eql({foo: 'bar'});
data.foo = 'bad2';
});
await ipc.publish<MsgType>('foo', 'pub1', payload);
await sleep(0);
// allowing subscribers to change the data they received should not change the original obj
expect(payload).to.eql({foo: 'bar'});
// changing original object should not change what was published
payload.foo = 'new';
expect(sub1.getMessage()!.data).to.eql({foo: 'bar'});
});
});
describe('getMessage', function () {
it('should get the message previously published, even if previously retrieved', async function () {
const ipc = new AppiumIpc();
const payload1 = {hello: 'world'};
const payload2 = 3;
await ipc.publish('foo', 'bar', payload1);
await ipc.publish('foo', 'baz', payload2);
await ipc.publish('other', 'bar', 5); // should not get message published on other topic
expect((ipc.getMessage('foo'))!.data).to.eql(payload2);
});
it('should get the message from the subscription object', async function () {
const ipc = new AppiumIpc();
const payload1 = {hello: 'world'};
type PayloadType = typeof payload1;
const sub1 = ipc.subscribe<PayloadType>('foo', 'bar');
const sub2 = ipc.subscribe<PayloadType>('foo', 'baz');
await sub1.publish(payload1);
expect(sub2.getMessage()!.data).to.eql(payload1);
});
it('should be no race conditions with publishing', async function () {
const ipc = new AppiumIpc();
const p = ipc.publish('foo', 'bar', true); // intentionally avoid waiting
const msg = ipc.getMessage('foo');
expect(msg!.data).to.eql(true);
await p; // just make sure promise is done
});
it('should not allow getting message from subscription object after unsubscribing', function () {
const ipc = new AppiumIpc();
const sub1 = ipc.subscribe<boolean>('foo', 'bar');
sub1.unsubscribe();
expect(() => sub1.getMessage()).to.throw;
});
it('should get messages from the async iterator of the subscription object', async function () {
const ipc = new AppiumIpc();
const payload1 = {hello: 'world'};
const payload2 = {hello: 'goodbye'};
const payload3 = {hello: 'everyone'};
type PayloadType = {hello: string};
const received: PayloadType[] = [];
const sub1 = ipc.subscribe<PayloadType>('foo', 'bar');
const sub2 = ipc.subscribe<PayloadType>('foo', 'baz');
const sendLoop = async () => {
await sleep(50);
await sub1.publish(payload1);
await sub1.publish(payload2);
await sub1.publish(payload3);
};
const rcvLoop = async () => {
let i = 0;
for await (const message of sub2) {
i++;
expect(message.publisher).to.eql('bar');
expect(message.topic).to.eql('foo');
received.push(message.data);
if (i >= 3) {
break;
}
}
};
await Promise.all([rcvLoop(), sendLoop()]);
expect(received).to.eql([payload1, payload2, payload3]);
});
it('should stop async iterator after unsubscribe', async function () {
const ipc = new AppiumIpc();
const payload1 = {hello: 'world'};
const payload2 = {hello: 'goodbye'};
const payload3 = {hello: 'everyone'};
type PayloadType = {hello: string};
const received: PayloadType[] = [];
const sub1 = ipc.subscribe<PayloadType>('foo', 'bar');
const sub2 = ipc.subscribe<PayloadType>('foo', 'baz');
const sendLoop = async () => {
await sleep(20);
await sub1.publish(payload1);
await sleep(20);
sub2.unsubscribe(); // unsubscribe the receiver here but keep publishing
await sub1.publish(payload2);
await sub1.publish(payload3);
};
const rcvLoop = async () => {
for await (const message of sub2) {
expect(message.publisher).to.eql('bar');
expect(message.topic).to.eql('foo');
received.push(message.data);
}
};
await Promise.all([rcvLoop(), sendLoop()]);
expect(received).to.eql([payload1]); // should only have one payload since we unsubscribed
});
});
describe('isActive', function () {
it('should tell the truth about subscription status', function () {
const ipc = new AppiumIpc();
const sub1 = ipc.subscribe<boolean>('foo', 'bar');
expect(sub1.isActive).to.be.true;
sub1.unsubscribe();
expect(sub1.isActive).to.be.false;
});
});
});
@@ -1,6 +1,5 @@
import chai, {expect} from 'chai';
import chaiAsPromised from 'chai-as-promised';
import _ from 'lodash';
import {PROTOCOLS} from '../../../lib/constants';
import {
COMMAND_URLS_CONFLICTS,
@@ -24,7 +23,7 @@ describe('Protocol Converter', function () {
describe('getTimeoutRequestObjects', function () {
let converter: ProtocolConverter;
before(function () {
converter = new ProtocolConverter(_.noop as any);
converter = new ProtocolConverter((() => {}) as any);
});
it('should take W3C inputs and produce MJSONWP compatible objects', function () {
converter.downstreamProtocol = MJSONWP;
@@ -1,6 +1,5 @@
import chai, {expect} from 'chai';
import chaiAsPromised from 'chai-as-promised';
import _ from 'lodash';
import {statusCodes, getSummaryByCode} from '../../../lib';
chai.use(chaiAsPromised);
@@ -8,7 +7,7 @@ chai.use(chaiAsPromised);
describe('jsonwp-status', function () {
describe('codes', function () {
it('should export code numbers and summaries', function () {
for (const obj of _.values(statusCodes)) {
for (const obj of Object.values(statusCodes)) {
expect(obj).to.have.property('code');
expect(obj.code).to.be.a('number');
expect(obj).to.have.property('summary');
@@ -1,7 +1,6 @@
import {expect} from 'chai';
import {errors, errorFromMJSONWPStatusCode, errorFromW3CJsonCode, isErrorType} from '../../../lib';
import {BadParametersError, getResponseForW3CError} from '../../../lib/protocol/errors';
import _ from 'lodash';
import {StatusCodes as HTTPStatusCodes} from 'http-status-codes';
import path from 'node:path';
@@ -77,7 +76,7 @@ describe('errorFromMJSONWPStatusCode', function () {
if (error.errorCode) {
expect(errorFromMJSONWPStatusCode(error.errorCode)).to.have.property('jsonwpCode', error.errorCode);
expect(errorFromMJSONWPStatusCode(error.errorCode)).to.have.property('message', error.errorMsg);
if (!_.includes([13, 33], error.errorCode)) {
if (![13, 33].includes(error.errorCode)) {
expect(errorFromMJSONWPStatusCode(error.errorCode, 'abcd')).to.have.property('jsonwpCode', error.errorCode);
expect(errorFromMJSONWPStatusCode(error.errorCode, 'abcd')).to.have.property('message', 'abcd');
}
@@ -1,7 +1,6 @@
import chai, {expect} from 'chai';
import chaiAsPromised from 'chai-as-promised';
import type {HTTPMethod} from '@appium/types';
import _ from 'lodash';
import {METHOD_MAP, routeToCommandName} from '../../../lib/protocol';
import crypto from 'node:crypto';
@@ -13,18 +12,18 @@ describe('Routes', function () {
// protocol
it('should not change protocol between patch versions', function () {
const shasum = crypto.createHash('sha1');
for (const [url, urlMapping] of _.toPairs(METHOD_MAP)) {
for (const [url, urlMapping] of Object.entries(METHOD_MAP)) {
shasum.update(url);
for (const [method, methodMapping] of _.toPairs(urlMapping as Record<string, {command?: string; payloadParams?: {required?: any[]; optional?: any[]; wrap?: string}}>)) {
for (const [method, methodMapping] of Object.entries(urlMapping as Record<string, {command?: string; payloadParams?: {required?: any[]; optional?: any[]; wrap?: string}}>)) {
shasum.update(method);
if (methodMapping.command) {
shasum.update(methodMapping.command);
}
if (methodMapping.payloadParams) {
let allParams = _.flatten(methodMapping.payloadParams.required ?? []);
let allParams = (methodMapping.payloadParams.required ?? []).flat();
if (methodMapping.payloadParams.optional) {
allParams = allParams.concat(
_.flatten(methodMapping.payloadParams.optional)
(methodMapping.payloadParams.optional ?? []).flat()
);
}
for (const param of allParams) {
@@ -0,0 +1,98 @@
import {expect} from 'chai';
import {
compileLodashTemplate,
mergePlainObjects,
omit,
omitKeys,
pick,
pickBy,
} from '../../lib/utils';
describe('utils', function () {
describe('mergePlainObjects', function () {
it('should deep-merge plain objects without mutating the target', function () {
const target: Record<string, unknown> = {a: 1, nested: {x: 1, y: 2}};
const result = mergePlainObjects(
target,
{b: 2, nested: {y: 3, z: 4}},
undefined
);
expect(result).to.eql({a: 1, b: 2, nested: {x: 1, y: 3, z: 4}});
expect(target).to.eql({a: 1, nested: {x: 1, y: 2}});
});
it('should skip null and undefined sources', function () {
const target: Record<string, unknown> = {a: 1};
expect(mergePlainObjects(target, undefined, {b: 2})).to.eql({a: 1, b: 2});
expect(mergePlainObjects(target, null as any, {b: 2})).to.eql({a: 1, b: 2});
});
it('should replace nested values when the source value is not a plain object', function () {
const target: Record<string, unknown> = {nested: {a: 1}};
expect(mergePlainObjects(target, {nested: 'replaced'})).to.eql({
nested: 'replaced',
});
});
});
describe('omit', function () {
it('should omit a key from a plain object', function () {
expect(omit({a: 1, b: 2}, 'a')).to.eql({b: 2});
});
it('should return non-plain objects unchanged', function () {
expect(omit(null as any, 'a')).to.equal(null);
expect(omit('text' as any, 'a')).to.equal('text');
});
});
describe('omitKeys', function () {
it('should omit multiple keys', function () {
expect(omitKeys({a: 1, b: 2, c: 3}, ['a', 'c'])).to.eql({b: 2});
});
it('should return the same object when keys is empty', function () {
const obj = {a: 1};
expect(omitKeys(obj, [])).to.equal(obj);
});
it('should return non-plain objects unchanged', function () {
expect(omitKeys(null as any, ['a'])).to.equal(null);
});
});
describe('pick', function () {
it('should pick only the listed keys', function () {
expect(pick({a: 1, b: 2, c: 3}, ['a', 'c'])).to.eql({a: 1, c: 3});
});
});
describe('pickBy', function () {
it('should keep entries that pass the predicate', function () {
expect(pickBy({a: 1, b: '', c: 3}, (value) => value !== '')).to.eql({a: 1, c: 3});
});
});
describe('compileLodashTemplate', function () {
it('should render static text', function () {
const render = compileLodashTemplate('hello');
expect(render({})).to.equal('hello');
});
it('should interpolate template parameters', function () {
const render = compileLodashTemplate('Hello <%= message %>!');
expect(render({message: 'world'})).to.equal('Hello world!');
});
it('should evaluate javascript expressions in templates', function () {
const render = compileLodashTemplate('<%= one + two %>');
expect(render({one: 1, two: 2})).to.equal('3');
});
it('should render multiple interpolations', function () {
const render = compileLodashTemplate('<%= a %>-<%= b %>');
expect(render({a: 'x', b: 'y'})).to.equal('x-y');
});
});
});
+25
View File
@@ -3,6 +3,31 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.3.0](https://github.com/appium/appium/compare/@appium/base-plugin@3.2.4...@appium/base-plugin@3.3.0) (2026-05-31)
### Features
* add IPC channels per session for drivers and plugins to communicate ([#22211](https://github.com/appium/appium/issues/22211)) ([97c879c](https://github.com/appium/appium/commit/97c879cdd02e6fcccaaea45a8d1a7088bed4ae66)), closes [#22206](https://github.com/appium/appium/issues/22206)
## [3.2.4](https://github.com/appium/appium/compare/@appium/base-plugin@3.2.3...@appium/base-plugin@3.2.4) (2026-05-07)
**Note:** Version bump only for package @appium/base-plugin
## [3.2.3](https://github.com/appium/appium/compare/@appium/base-plugin@3.2.2...@appium/base-plugin@3.2.3) (2026-05-06)
**Note:** Version bump only for package @appium/base-plugin
## [3.2.2](https://github.com/appium/appium/compare/@appium/base-plugin@3.2.1...@appium/base-plugin@3.2.2) (2026-05-06)
**Note:** Version bump only for package @appium/base-plugin
+2 -1
View File
@@ -8,6 +8,7 @@ import type {
Constraints,
Driver,
ExecuteMethodMap,
ExternalDriver,
MethodMap,
NextPluginCallback,
Plugin,
@@ -71,7 +72,7 @@ export class BasePlugin extends ExtensionCore implements Plugin {
const command = this[
commandMetadata.command as keyof this
] as PluginCommand<Driver<C>>;
] as PluginCommand<ExternalDriver<C>>;
const args = validateExecuteMethodParams(
protoArgs as unknown[],
commandMetadata.params
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@appium/base-plugin",
"version": "3.2.2",
"version": "3.3.0",
"description": "The base plugin used to create Appium plugins",
"keywords": [
"automation",
@@ -40,9 +40,9 @@
"test:unit": "mocha \"./test/unit/**/*.spec.*ts\""
},
"dependencies": {
"@appium/base-driver": "10.5.0",
"@appium/support": "7.2.0",
"@appium/types": "1.4.0"
"@appium/base-driver": "10.6.0",
"@appium/support": "7.2.3",
"@appium/types": "1.5.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
+24
View File
@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.4.3](https://github.com/appium/appium/compare/@appium/docutils@2.4.2...@appium/docutils@2.4.3) (2026-05-31)
**Note:** Version bump only for package @appium/docutils
## [2.4.2](https://github.com/appium/appium/compare/@appium/docutils@2.4.1...@appium/docutils@2.4.2) (2026-05-07)
**Note:** Version bump only for package @appium/docutils
## [2.4.1](https://github.com/appium/appium/compare/@appium/docutils@2.4.0...@appium/docutils@2.4.1) (2026-05-06)
**Note:** Version bump only for package @appium/docutils
## [2.4.0](https://github.com/appium/appium/compare/@appium/docutils@2.3.1...@appium/docutils@2.4.0) (2026-05-06)
+10 -5
View File
@@ -4,7 +4,6 @@
* @module
*/
import _ from 'lodash';
import path from 'node:path';
import type {TeenProcessExecOptions} from 'teen_process';
import {
@@ -169,7 +168,11 @@ export async function deploy({
}
if (serve) {
const mikeArgs = [
...argify(_.pickBy(mikeOpts, (value) => _.isNumber(value) || Boolean(value))),
...argify(
Object.fromEntries(
Object.entries(mikeOpts).filter(([, value]) => typeof value === 'number' || Boolean(value))
)
),
];
stop(); // discard
// unsure about how SIGHUP is handled here
@@ -178,9 +181,11 @@ export async function deploy({
log.info('Deploying into branch %s', branch);
const mikeArgs = [
...argify(
_.omitBy(
mikeOpts,
(value, key) => _.includes(['port', 'host'], key) || (!_.isNumber(value) && !value),
Object.fromEntries(
Object.entries(mikeOpts).filter(
([key, value]) =>
!['port', 'host'].includes(key) && (typeof value === 'number' || Boolean(value))
)
),
),
];
+10 -9
View File
@@ -4,9 +4,9 @@
*/
import {fs, util} from '@appium/support';
import _ from 'lodash';
import type {Options} from 'yargs';
import {getLogger} from '../logger';
import {kebabCase} from '../util';
const log = getLogger('check');
@@ -41,17 +41,17 @@ export async function checkMissingPaths<T extends Record<string, Options>>(
group: string,
argv: Record<string, unknown>
): Promise<true | string> {
const argsToCheck = _.keys(
_.pickBy(opts, (opt: Options, id: string) => opt?.group === group && id in argv) as Partial<T>
const argsToCheck = Object.keys(opts).filter(
(id) => opts[id]?.group === group && id in argv
);
// yargs is pretty loose about allowing CLI args multiple times, and the value could potentially
// be a `string[]` instead of `string`; we don't want to allow more than one path per arg.
if (!argsToCheck.every((id) => _.isString(argv[id]))) {
if (!argsToCheck.every((id) => typeof argv[id] === 'string')) {
return 'Paths may only be provided once each';
}
const pathsToCheck: MissingFileData[] = _.map(argsToCheck, (id) => ({
const pathsToCheck: MissingFileData[] = argsToCheck.map((id) => ({
id,
path: String(argv[id]), // this must be a string per the above check
}));
@@ -59,7 +59,7 @@ export async function checkMissingPaths<T extends Record<string, Options>>(
log.debug(
'Checking for existence of %s: %s',
util.pluralize('path', pathsToCheck.length),
_.map(pathsToCheck, 'path')
pathsToCheck.map(({path}) => path)
);
const missingPaths = await filterMissing(pathsToCheck);
@@ -68,7 +68,7 @@ export async function checkMissingPaths<T extends Record<string, Options>>(
return missingPaths
.map(
({id, path}) =>
`Default or specified path via --${_.kebabCase(id)} (${path}) does not exist`
`Default or specified path via --${kebabCase(id)} (${path}) does not exist`
)
.join('\n');
}
@@ -85,6 +85,7 @@ async function filterMissing(paths: MissingFileData[]): Promise<MissingFileData[
const exists = await Promise.all(
paths.map(async ({id, path}) => ({id, path, exists: await fs.exists(path)}))
);
const results = _.reject(exists, 'exists');
return _.map(results, (result) => _.omit(result, 'exists'));
return exists
.filter((result) => !result.exists)
.map(({id, path}) => ({id, path}));
}
+1 -2
View File
@@ -5,7 +5,6 @@
import type {Loader} from 'lilconfig';
import {lilconfig} from 'lilconfig';
import _ from 'lodash';
import path from 'node:path';
import * as YAML from 'yaml';
import parser from 'yargs-parser';
@@ -20,7 +19,7 @@ const log = getLogger('config');
/**
* `lilconfig` loader for YAML
*/
const loadYaml: Loader = _.rearg(YAML.parse, [2, 0, 1]);
const loadYaml: Loader = (_filepath, content) => YAML.parse(content);
/**
* Controls how we load/find a config file.
+1 -2
View File
@@ -9,7 +9,6 @@
import {getLogger} from '../logger';
import {fs} from '@appium/support';
import _ from 'lodash';
import {readPackageSync} from 'read-pkg';
import {hideBin} from 'yargs/helpers';
import yargs from 'yargs/yargs';
@@ -46,7 +45,7 @@ export async function main(argv = hideBin(process.argv)) {
choices: ['debug', 'info', 'warn', 'error', 'silent'],
describe: 'Sets the log level',
default: DEFAULT_LOG_LEVEL,
coerce: _.identity as (x: string) => keyof typeof LogLevelMap,
coerce: ((x: string) => x) as (x: string) => keyof typeof LogLevelMap,
},
config: {
alias: 'c',
+22 -27
View File
@@ -3,8 +3,7 @@
* @module
*/
import {fs} from '@appium/support';
import _ from 'lodash';
import {fs, util} from '@appium/support';
import path from 'node:path';
import {packageDirectory} from 'package-directory';
import type {NormalizedPackageJson, PackageJson} from 'read-pkg';
@@ -23,6 +22,7 @@ import {DocutilsError} from './error';
import {getLogger} from './logger';
import type {MkDocsYml} from './model';
import {exec} from 'teen_process';
import {mergeDefaultsDeep} from './util';
const log = getLogger('fs');
@@ -31,34 +31,27 @@ const log = getLogger('fs');
*
* Caches result
*/
const findPkgDir = _.memoize(packageDirectory, (opts) => opts?.cwd);
const findPkgDir = util.memoize(packageDirectory, (opts) => opts?.cwd);
/**
* Stringifies a thing into a YAML
* @param value Something to yamlify
* @returns Some nice YAML 4 u
*/
export const stringifyYaml: (value: JsonValue) => string = _.partialRight(
YAML.stringify,
{indent: 2},
undefined,
);
export const stringifyYaml = (value: JsonValue): string =>
YAML.stringify(value, undefined, {indent: 2});
/**
* Pretty-stringifies a JSON value
* @param value Something to stringify
* @returns JSON string
*/
export const stringifyJson: (value: JsonValue) => string = _.partialRight(
JSON.stringify,
2,
undefined,
);
export const stringifyJson = (value: JsonValue): string => JSON.stringify(value, undefined, 2);
/**
* Reads a YAML file, parses it and caches the result
*/
const readYaml = _.memoize(async (filepath: string) =>
const readYaml = util.memoize(async (filepath: string) =>
YAML.parse(await fs.readFile(filepath, 'utf8'), {
prettyErrors: false,
logLevel: 'silent',
@@ -90,7 +83,9 @@ export async function findInPkgDir(
* @param cwd - Current working directory
* @returns Path to `mkdocs.yml`
*/
export const findMkDocsYml = _.memoize(_.partial(findInPkgDir, NAME_MKDOCS_YML));
export const findMkDocsYml = util.memoize(
async (cwd = process.cwd()) => await findInPkgDir(NAME_MKDOCS_YML, cwd)
);
/**
* Given a directory path, finds closest `package.json` and reads it.
@@ -130,12 +125,12 @@ async function _readPkgJson(
/**
* Given a directory to start from, reads a `package.json` file and returns its path and contents
*/
export const readPackageJson = _.memoize(_readPkgJson);
export const readPackageJson = util.memoize(_readPkgJson);
/**
* Reads a JSON file and parses it
*/
export const readJson = _.memoize(
export const readJson = util.memoize(
async <T extends JsonValue>(filepath: string): Promise<T> =>
JSON.parse(await fs.readFile(filepath, 'utf8')),
);
@@ -148,7 +143,7 @@ type WhichFunction = (cmd: string, opts?: {nothrow: boolean}) => Promise<string
* @param content - File contents
*/
export function writeFileString(filepath: string, content: JsonValue) {
const data: string = _.isString(content) ? content : JSON.stringify(content, undefined, 2);
const data: string = typeof content === 'string' ? content : JSON.stringify(content, undefined, 2);
return fs.writeFile(filepath, data, {
encoding: 'utf8',
});
@@ -157,22 +152,22 @@ export function writeFileString(filepath: string, content: JsonValue) {
/**
* `which` with memoization
*/
const cachedWhich = _.memoize(fs.which as WhichFunction);
const cachedWhich = util.memoize(fs.which as WhichFunction);
/**
* Finds `python` executable
*/
const whichPython = _.partial(cachedWhich, NAME_PYTHON, {nothrow: true});
const whichPython = async () => await cachedWhich(NAME_PYTHON, {nothrow: true});
/**
* Finds `python3` executable
*/
const whichPython3 = _.partial(cachedWhich, `${NAME_PYTHON}3`, {nothrow: true});
const whichPython3 = async () => await cachedWhich(`${NAME_PYTHON}3`, {nothrow: true});
/**
* Check if `mkdocs` is installed
*/
export const isMkDocsInstalled = _.memoize(async (): Promise<boolean> => {
export const isMkDocsInstalled = util.memoize(async (): Promise<boolean> => {
// see if it's in PATH
const mkDocsPath = await cachedWhich(NAME_MKDOCS, {nothrow: true});
if (mkDocsPath) {
@@ -194,7 +189,7 @@ export const isMkDocsInstalled = _.memoize(async (): Promise<boolean> => {
/**
* `mike` cannot be invoked via `python -m`, so we need to find the script.
*/
export const findMike = _.partial(async () => {
export const findMike = async () => {
// see if it's in PATH
let mikePath = await cachedWhich(NAME_MIKE, {nothrow: true});
if (mikePath) {
@@ -216,14 +211,14 @@ export const findMike = _.partial(async () => {
}
}
} catch {}
});
};
/**
* Finds the `python3` or `python` executable in the user's `PATH`.
*
* `python3` is preferred over `python`, since the latter could be Python 2.
*/
export const findPython = _.memoize(
export const findPython = util.memoize(
async (): Promise<string | null> => (await whichPython3()) ?? (await whichPython()),
);
@@ -246,7 +241,7 @@ export async function requirePython(pythonPath?: string): Promise<string> {
* @param filepath Patgh to an `mkdocs.yml` file
* @returns Parsed `mkdocs.yml` file
*/
export const readMkDocsYml = _.memoize(
export const readMkDocsYml: (filepath: string, cwd?: string) => Promise<MkDocsYml> = util.memoize(
async (filepath: string, cwd = process.cwd()): Promise<MkDocsYml> => {
let mkDocsYml = (await readYaml(filepath)) as MkDocsYml;
if (mkDocsYml.site_dir) {
@@ -264,7 +259,7 @@ export const readMkDocsYml = _.memoize(
inheritYml.docs_dir = path.resolve(path.dirname(inheritPath), inheritYml.docs_dir);
log.debug('Resolved docs_dir to %s', inheritYml.docs_dir);
}
mkDocsYml = _.defaultsDeep(mkDocsYml, inheritYml);
mkDocsYml = mergeDefaultsDeep(mkDocsYml, inheritYml);
inheritPath = inheritYml.INHERIT
? path.resolve(path.dirname(inheritPath), inheritYml.INHERIT)
: undefined;
+7 -3
View File
@@ -9,7 +9,6 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {ConsolaInstance, createConsola, LogLevel} = require('consola');
import _ from 'lodash';
import {DEFAULT_LOG_LEVEL, LogLevelMap} from './constants';
/**
@@ -72,7 +71,12 @@ export function getLogger(tag: string, parent = rootLogger) {
*
* @remarks Child loggers seem to inherit the "paused" state of the parent, so when this is called, we must resume all of them.
*/
export const initLogger = _.once((level: keyof typeof LogLevelMap | typeof LogLevel) => {
let hasInitializedLogger = false;
export const initLogger = (level: keyof typeof LogLevelMap | typeof LogLevel) => {
if (hasInitializedLogger) {
return;
}
hasInitializedLogger = true;
globalLevel = isLogLevelString(level) ? LogLevelMap[level] : level;
rootLogger.level = globalLevel;
rootLogger.resumeLogs();
@@ -83,4 +87,4 @@ export const initLogger = _.once((level: keyof typeof LogLevelMap | typeof LogLe
logger.resumeLogs();
}
}
});
};
+7 -8
View File
@@ -3,15 +3,14 @@
* @module
*/
import {fs} from '@appium/support';
import {fs, util} from '@appium/support';
import {getLogger} from './logger';
import path from 'node:path';
import {createPatch} from 'diff';
import type {NormalizedPackageJson} from 'read-pkg';
import type {JsonValue, JsonObject} from 'type-fest';
import {DocutilsError} from './error';
import {relative} from './util';
import _ from 'lodash';
import {mergeDefaultsDeep, relative} from './util';
import {stringifyJson, readPackageJson, writeFileString} from './fs';
import {NAME_ERR_ENOENT} from './constants';
@@ -127,7 +126,7 @@ export function createScaffoldTask<Opts extends ScaffoldTaskOptions, T extends J
defaultContent: T,
description: string,
{
transform = _.identity,
transform = (content) => content,
deserialize = JSON.parse,
serialize = stringifyJson,
}: CreateScaffoldTaskOptions<Opts, T> = {}
@@ -168,9 +167,9 @@ export function createScaffoldTask<Opts extends ScaffoldTaskOptions, T extends J
}
const defaults: T = transform(defaultContent, opts, pkg);
const finalDestContent: T = _.defaultsDeep({}, destContent, defaults);
const finalDestContent: T = mergeDefaultsDeep(destContent, defaults);
destChangesNeeded = destChangesNeeded || !_.isEqual(destContent, finalDestContent);
destChangesNeeded = destChangesNeeded || !util.isEqual(destContent, finalDestContent);
if (destChangesNeeded) {
log.info('Changes needed in %s', relativeDest);
@@ -225,7 +224,7 @@ function makePatch<T extends JsonValue>(
) {
return createPatch(
filename,
_.isString(oldData) ? oldData : serializer(oldData),
_.isString(newData) ? newData : serializer(newData)
typeof oldData === 'string' ? oldData : serializer(oldData),
typeof newData === 'string' ? newData : serializer(newData)
);
}
+69 -26
View File
@@ -1,21 +1,21 @@
/**
* Utilities
* @module
*/
import _ from 'lodash';
import type {SpawnOptions} from 'node:child_process';
import {spawn} from 'node:child_process';
import path from 'node:path';
import type {ExecError, TeenProcessExecOptions} from 'teen_process';
import {exec} from 'teen_process';
import {util as supportUtil} from '@appium/support';
/**
* Computes a relative path, prepending `./`
*/
export const relative = _.curry(
(from: string, to: string): string => `.${path.sep}${path.relative(from, to)}`
);
export function relative(from: string): (to: string) => string;
export function relative(from: string, to: string): string;
export function relative(from: string, to?: string): string | ((to: string) => string) {
if (to === undefined) {
return (nextTo: string) => `.${path.sep}${path.relative(from, nextTo)}`;
}
return `.${path.sep}${path.relative(from, to)}`;
}
/**
* A stopwatch-like thing
@@ -48,9 +48,8 @@ export type TupleToObject<
* @param value any value
* @returns `true` if the array is `string[]`
*/
export const isStringArray = _.overEvery(_.isArray, _.partial(_.every, _, _.isString)) as (
value: any
) => value is string[];
export const isStringArray = (value: any): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === 'string');
/**
* Converts an object of string values to an array of arguments for CLI
@@ -58,23 +57,67 @@ export const isStringArray = _.overEvery(_.isArray, _.partial(_.every, _, _.isSt
* Supports `boolean` and `number` values as well. `boolean`s are assumed to be flags which default
* to `false`, so they will only be added to the array if the value is `true`.
*/
export const argify: (obj: Record<string, string | number | boolean | undefined>) => string[] =
_.flow(
_.entries,
(list) =>
list.map(([key, value]) => {
if (value === true) {
return [`--${key}`];
} else if (value === false || value === undefined) {
return;
}
return [`--${key}`, value];
}),
_.flatten
);
export const argify = (obj: Record<string, string | number | boolean | undefined>): string[] => {
const args: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (value === true) {
args.push(`--${key}`);
} else if (value === false || value === undefined) {
continue;
} else {
args.push(`--${key}`, String(value));
}
}
return args;
};
export type SpawnBackgroundProcessOpts = Omit<SpawnOptions, 'stdio'>;
/**
* Converts a string to kebab-case.
* @param value Input string
* @returns Kebab-cased string
*/
export function kebabCase(value: string): string {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[_\s]+/g, '-')
.toLowerCase();
}
/**
* Performs a deep "defaults" merge into a clone of `target`.
* Only undefined properties in `target` are filled from `defaults`.
* @param target Destination object
* @param defaults Default values object
* @returns Merged clone
*/
export function mergeDefaultsDeep<T extends Record<string, any>>(target: T, defaults: T): T {
const out = structuredClone(target);
const stack: Array<{dest: Record<string, any>; src: Record<string, any>}> = [
{dest: out, src: defaults},
];
while (stack.length) {
const next = stack.pop();
if (!next) {
continue;
}
const {dest, src} = next;
for (const [key, srcVal] of Object.entries(src)) {
const destVal = dest[key];
if (destVal === undefined) {
dest[key] = structuredClone(srcVal);
continue;
}
if (supportUtil.isPlainObject(destVal) && supportUtil.isPlainObject(srcVal)) {
stack.push({dest: destVal, src: srcVal});
}
}
}
return out as T;
}
/**
* Spawns a long-running "background" child process. This is expected to only return control to the
* parent process in the case of a nonzero exit code from the child process.
+3 -4
View File
@@ -5,7 +5,6 @@
*/
import {fs, util} from '@appium/support';
import _ from 'lodash';
import {EventEmitter} from 'node:events';
import {exec} from 'teen_process';
import {
@@ -184,7 +183,7 @@ export class DocutilsValidator extends EventEmitter {
* @returns
*/
protected fail(err: DocutilsError | string) {
const dErr = _.isString(err) ? new DocutilsError(err) : err;
const dErr = typeof err === 'string' ? new DocutilsError(err) : err;
if (!this.emittedErrors.has(dErr.message)) {
this.emit(DocutilsValidator.FAILURE, dErr);
}
@@ -265,7 +264,7 @@ export class DocutilsValidator extends EventEmitter {
}
const version = match[1];
const reqs = await this.parseRequirementsTxt();
const mkDocsPipPkg = _.find(reqs, {name: NAME_MKDOCS});
const mkDocsPipPkg = reqs.find((pkg) => pkg.name === NAME_MKDOCS);
if (!mkDocsPipPkg) {
throw new DocutilsError(
`No ${NAME_MKDOCS} package in ${REQUIREMENTS_TXT_PATH}. This is a bug`,
@@ -350,7 +349,7 @@ export class DocutilsValidator extends EventEmitter {
return this.fail(`Could not parse output of "${NAME_PIP} list" as JSON: ${pipListOutput}`);
}
const pkgsByName = _.mapValues(_.keyBy(installedPkgs, 'name'), 'version');
const pkgsByName = Object.fromEntries(installedPkgs.map((pkg) => [pkg.name, pkg.version]));
log.debug('Installed Python packages: %O', pkgsByName);
const requiredPackages = await this.parseRequirementsTxt();
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@appium/docutils",
"version": "2.4.0",
"version": "2.4.3",
"description": "Documentation generation utilities for Appium and related projects",
"keywords": [
"automation",
@@ -46,16 +46,15 @@
"start": "node ./build/lib/cli/index.js"
},
"dependencies": {
"@appium/support": "7.2.0",
"@appium/support": "7.2.3",
"consola": "3.4.2",
"diff": "9.0.0",
"lilconfig": "3.1.3",
"lodash": "4.18.1",
"package-directory": "8.2.0",
"read-pkg": "10.1.0",
"teen_process": "4.1.3",
"type-fest": "5.6.0",
"yaml": "2.8.4",
"yaml": "2.9.0",
"yargs": "18.0.0",
"yargs-parser": "22.0.0"
},
+1 -1
View File
@@ -1,5 +1,5 @@
mkdocs==1.6.1
mkdocs-git-revision-date-localized-plugin==1.5.1
mkdocs-git-revision-date-localized-plugin==1.5.2
mkdocs-material==9.7.6
mkdocs-redirects==1.2.3
mike==2.2.0
+5 -4
View File
@@ -1,5 +1,4 @@
import {argify} from '../../lib/util';
import _ from 'lodash';
import * as chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
@@ -22,9 +21,11 @@ describe('argify', function () {
};
const mikeArgs = [
...argify(
_.omitBy(
mikeOpts,
(value, key) => _.includes(['port', 'host'], key) || (!_.isNumber(value) && !value),
Object.fromEntries(
Object.entries(mikeOpts).filter(
([key, value]) =>
!['port', 'host'].includes(key) && (typeof value === 'number' || Boolean(value))
)
),
),
version,
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.2.3](https://github.com/appium/appium/compare/@appium/driver-test-support@1.2.2...@appium/driver-test-support@1.2.3) (2026-05-31)
**Note:** Version bump only for package @appium/driver-test-support
## [1.2.2](https://github.com/appium/appium/compare/@appium/driver-test-support@1.2.1...@appium/driver-test-support@1.2.2) (2026-05-06)
**Note:** Version bump only for package @appium/driver-test-support
+9 -18
View File
@@ -59,7 +59,6 @@ These are just some helpers (mainly for E2E tests):
```js
import {TEST_HOST, getTestPort, createAppiumURL} from '@appium/driver-test-support';
import assert from 'node:assert';
import _ from 'lodash';
describe('TEST_HOST', function() {
it('should be localhost', function() {
@@ -75,26 +74,18 @@ describe('getTestPort()', function() {
});
describe('createAppiumURL()', function() {
const urlFor = createAppiumURL(TEST_HOST, 31337);
it('should create a "new session" URL', function() {
const actual = createAppiumURL(TEST_HOST, 31337, '', 'session');
const expected = `http://${TEST_HOST}:31337/session`;
assert.strictEqual(actual, expected);
assert.strictEqual(urlFor('', 'session'), `http://${TEST_HOST}:31337/session`);
});
it('should create a URL to get an existing session', function() {
it('should create a URL for a session command', function() {
const sessionId = '12345';
const createGetSessionURL = createAppiumURL(TEST_HOST, 31337, _, 'session');
const actual = createGetSessionURL(sessionId);
const expected = `http://${TEST_HOST}:31337/session/${sessionId}/session`;
assert.strictEqual(actual, expected);
});
it('should create a URL for a command using an existing session', function() {
const sessionId = '12345';
const createURLWithPath = createAppiumURL('127.0.0.1', 31337, sessionId);
const actual = createURLWithPath('moocow');
const expected = `http://${TEST_HOST}:31337/session/${sessionId}/moocow`;
assert.strictEqual(actual, expected);
assert.strictEqual(
urlFor(sessionId, 'moocow'),
`http://${TEST_HOST}:31337/session/${sessionId}/moocow`
);
});
});
```
+13 -13
View File
@@ -1,4 +1,3 @@
import _ from 'lodash';
import {server, routeConfiguringFunction, DeviceSettings} from 'appium/driver';
import axios, {type RawAxiosRequestConfig} from 'axios';
import {sleep} from 'asyncbox';
@@ -6,7 +5,7 @@ import {TEST_HOST, getTestPort, createAppiumURL} from './helpers';
import sinon from 'sinon';
import {Agent} from 'node:http';
import type {BaseNSCapabilities, Driver, DriverClass, SingularSessionData} from '@appium/types';
import type {NewSessionResponse, SessionHelpers} from './types';
import type {NewSessionData, NewSessionResponse, SessionHelpers} from './types';
/**
* Creates some helper functions for E2E tests to manage sessions.
@@ -28,7 +27,7 @@ export function createSessionHelpers<CommandData = unknown, ResponseData = any>(
return response.data?.value;
},
getCommand: async (sessionIdOrCmdName, cmdNameOrConfig, config = {}) => {
if (!_.isString(cmdNameOrConfig)) {
if (typeof cmdNameOrConfig !== 'string') {
config = cmdNameOrConfig as RawAxiosRequestConfig;
cmdNameOrConfig = sessionIdOrCmdName;
sessionIdOrCmdName = '';
@@ -41,12 +40,7 @@ export function createSessionHelpers<CommandData = unknown, ResponseData = any>(
return response.data?.value;
},
startSession: async (data, config = {}) => {
data = _.defaultsDeep(data, {
capabilities: {
alwaysMatch: {},
firstMatch: [{}],
},
});
applyNewSessionDefaults(data);
const response = await axios.post(newSessionURL, data, config);
return response.data?.value;
},
@@ -141,7 +135,7 @@ export function driverE2ETestSuite(
sessionIds.push(sessionId);
times++;
} while (times < 2);
expect(_.uniq(sessionIds)).to.have.lengthOf(1);
expect([...new Set(sessionIds)]).to.have.lengthOf(1);
const {status, data} = await endSession(sessionIds[0]);
expect(status).to.equal(200);
@@ -177,8 +171,8 @@ export function driverE2ETestSuite(
);
times++;
} while (times < 2);
const sessionIds = _.map(await Promise.all(reqs), 'sessionId');
expect(_.uniq(sessionIds)).to.have.lengthOf(1);
const sessionIds = (await Promise.all(reqs)).map((r) => r.sessionId);
expect([...new Set(sessionIds)]).to.have.lengthOf(1);
const {status, data} = await endSession(sessionIds[0]);
expect(status).to.equal(200);
@@ -212,7 +206,7 @@ export function driverE2ETestSuite(
let originalFindElements: typeof d.findElements;
async function startTimeoutSession(timeout?: number) {
const caps = _.cloneDeep(defaultCaps);
const caps = structuredClone(defaultCaps);
(caps as any)['appium:newCommandTimeout'] = timeout;
return await startSession({capabilities: {alwaysMatch: caps}});
}
@@ -409,3 +403,9 @@ export function driverE2ETestSuite(
});
});
}
function applyNewSessionDefaults(data: NewSessionData): void {
data.capabilities ??= {alwaysMatch: {}, firstMatch: [{}]};
data.capabilities.alwaysMatch ??= {};
data.capabilities.firstMatch ??= [{}];
}
+40 -15
View File
@@ -1,5 +1,4 @@
import getPort from 'get-port';
import _ from 'lodash';
/**
* Default test host
@@ -25,20 +24,46 @@ export async function getTestPort(force = false): Promise<number> {
}
/**
* Build an Appium URL from components.
* Build Appium server URLs for tests.
*
* **All** parameters are required. Provide an empty string (`''`) if you don't need one.
* To rearrange arguments (if needed), use the placeholder from Lodash (`_`).
* Call with `(address, port)` to get `(session, pathname) => url`, or pass all four
* arguments at once. Use `''` when session or pathname is omitted.
*/
export const createAppiumURL = _.curry(
(address: string, port: string | number, session: string | null, pathname: string): string => {
if (!/^https?:\/\//.test(address)) {
address = `http://${address}`;
}
let path = session ? `session/${session}` : '';
if (pathname) {
path = `${path}/${pathname}`;
}
return new URL(path, `${address}:${port}`).href;
export function createAppiumURL(
address: string,
port: string | number
): (session: string, pathname: string) => string;
export function createAppiumURL(
address: string,
port: string | number,
session: string,
pathname: string
): string;
export function createAppiumURL(
address: string,
port: string | number,
session?: string,
pathname?: string
): string | ((session: string, pathname: string) => string) {
const urlFor = (sess: string, path: string) => buildAppiumURL(address, port, sess, path);
if (arguments.length === 2) {
return urlFor;
}
);
return urlFor(session!, pathname!);
}
function buildAppiumURL(
address: string,
port: string | number,
session: string,
pathname: string
): string {
let base = address;
if (!/^https?:\/\//.test(base)) {
base = `http://${base}`;
}
let path = session ? `session/${session}` : '';
if (pathname) {
path = `${path}/${pathname}`;
}
return new URL(path, `${base}:${port}`).href;
}
@@ -1,4 +1,3 @@
import _ from 'lodash';
import {sleep} from 'asyncbox';
import {createSandbox} from 'sinon';
import type {
@@ -70,15 +69,15 @@ export function driverUnitTestSuite<C extends Constraints>(
});
it('should not be able to start two sessions without closing the first', async function () {
await d.createSession(_.cloneDeep(w3cCaps));
await expect(d.createSession(_.cloneDeep(w3cCaps))).to.be.rejectedWith('session');
await d.createSession(structuredClone(w3cCaps));
await expect(d.createSession(structuredClone(w3cCaps))).to.be.rejectedWith('session');
});
it('should be able to delete a session', async function () {
const sessionId1 = await d.createSession(_.cloneDeep(w3cCaps));
const sessionId1 = await d.createSession(structuredClone(w3cCaps));
await d.deleteSession();
expect(d.sessionId).to.equal(null);
const sessionId2 = await d.createSession(_.cloneDeep(w3cCaps));
const sessionId2 = await d.createSession(structuredClone(w3cCaps));
expect(sessionId1).to.not.eql(sessionId2);
});
@@ -140,7 +139,7 @@ export function driverUnitTestSuite<C extends Constraints>(
DriverClass.prototype.deleteSession.call(this);
});
await d.createSession(_.cloneDeep(w3cCaps));
await d.createSession(structuredClone(w3cCaps));
const p = new Promise<void>((resolve, reject) => {
setTimeout(
() =>
@@ -159,7 +158,7 @@ export function driverUnitTestSuite<C extends Constraints>(
await expect(d.executeCommand('getSession')).to.be.rejectedWith(/shut down/);
await sleep(500);
await d.executeCommand('createSession', null, null, _.cloneDeep(w3cCaps));
await d.executeCommand('createSession', null, null, structuredClone(w3cCaps));
await d.deleteSession();
});
@@ -179,7 +178,7 @@ export function driverUnitTestSuite<C extends Constraints>(
describe('protocol detection', function () {
it('should use W3C if only W3C caps are provided', async function () {
await d.createSession({
alwaysMatch: _.clone(defaultCaps) as object,
alwaysMatch: {...defaultCaps} as object,
firstMatch: [{}],
});
expect(d.protocol).to.equal('W3C');
@@ -506,7 +505,7 @@ export function driverUnitTestSuite<C extends Constraints>(
let res = await d.getSession();
expect(res.events).to.not.exist;
_.set(d, 'caps.eventTimings', true);
(d.caps as Record<string, unknown>).eventTimings = true;
res = await d.getSession();
expect(res.events).to.exist;
expect(res.events?.newSessionRequested).to.exist;
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@appium/driver-test-support",
"version": "1.2.2",
"version": "1.2.3",
"description": "Test utilities for Appium drivers",
"keywords": [
"automation",
@@ -39,17 +39,16 @@
},
"types": "./build/lib/index.d.ts",
"dependencies": {
"asyncbox": "6.2.0",
"axios": "1.16.0",
"asyncbox": "6.3.0",
"axios": "1.16.1",
"chai": "6.2.2",
"chai-as-promised": "8.0.2",
"get-port": "7.2.0",
"lodash": "4.18.1",
"sinon": "21.1.2",
"sinon": "22.0.0",
"type-fest": "5.6.0"
},
"devDependencies": {
"@appium/types": "1.4.0"
"@appium/types": "1.5.0"
},
"peerDependencies": {
"appium": "^3.0.0-beta.0",
@@ -1,5 +1,4 @@
import {TEST_HOST, getTestPort, createAppiumURL} from '../../lib';
import _ from 'lodash';
describe('TEST_HOST', function () {
let expect: Chai.ExpectStatic;
@@ -32,32 +31,30 @@ describe('getTestPort()', function () {
describe('createAppiumURL()', function () {
let expect: Chai.ExpectStatic;
let urlFor: (session: string, pathname: string) => string;
before(async function () {
const chai = await import('chai');
(chai as any).should();
expect = (chai as any).expect;
urlFor = createAppiumURL(TEST_HOST, 31337);
});
it('should create a "new session" URL', function () {
const actual = createAppiumURL(TEST_HOST, 31337, '', 'session');
const expected = `http://${TEST_HOST}:31337/session`;
expect(actual).to.equal(expected);
expect(urlFor('', 'session')).to.equal(`http://${TEST_HOST}:31337/session`);
});
it('should create a URL to get an existing session', function () {
const sessionId = '12345';
const createGetSessionURL = createAppiumURL(TEST_HOST, 31337, _, 'session');
const actual = createGetSessionURL(sessionId);
const expected = `http://${TEST_HOST}:31337/session/${sessionId}/session`;
expect(actual).to.equal(expected);
expect(urlFor(sessionId, 'session')).to.equal(
`http://${TEST_HOST}:31337/session/${sessionId}/session`
);
});
it('should create a URL for a command using an existing session', function () {
const sessionId = '12345';
const createURLWithPath = createAppiumURL('127.0.0.1', 31337, sessionId);
const actual = createURLWithPath('moocow');
const expected = `http://${TEST_HOST}:31337/session/${sessionId}/moocow`;
expect(actual).to.equal(expected);
expect(urlFor(sessionId, 'moocow')).to.equal(
`http://${TEST_HOST}:31337/session/${sessionId}/moocow`
);
});
});
@@ -3,6 +3,23 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.2.2](https://github.com/appium/appium/compare/@appium/eslint-config-appium-ts@3.2.1...@appium/eslint-config-appium-ts@3.2.2) (2026-05-31)
**Note:** Version bump only for package @appium/eslint-config-appium-ts
## [3.2.1](https://github.com/appium/appium/compare/@appium/eslint-config-appium-ts@3.2.0...@appium/eslint-config-appium-ts@3.2.1) (2026-05-06)
### Bug Fixes
* **eslint-config-appium-ts:** set new ESLint 10 rules to 'warn' ([#22251](https://github.com/appium/appium/issues/22251)) ([98a1735](https://github.com/appium/appium/commit/98a1735913bcc02be7574156e0f13e1d05fcb87f))
## [3.2.0](https://github.com/appium/appium/compare/@appium/eslint-config-appium-ts@3.1.0...@appium/eslint-config-appium-ts@3.2.0) (2026-05-06)
@@ -179,6 +179,15 @@ export default defineConfig([
* `return await somePromise` have their own use-cases.
*/
'require-await': 'off',
/**
* These rules are enabled by default since ESLint 10.
* They are set to 'warn' to prevent a breaking change.
*/
'no-unassigned-vars': 'warn',
'no-useless-assignment': 'warn',
'preserve-caught-error': 'warn',
'unicorn/prefer-node-protocol': 'warn',
/**
@@ -1,7 +1,7 @@
{
"name": "@appium/eslint-config-appium-ts",
"type": "module",
"version": "3.2.0",
"version": "3.2.2",
"description": "Shared ESLint config for Appium projects (TypeScript version)",
"keywords": [
"eslint",
@@ -29,19 +29,19 @@
"test:smoke": "exit 0"
},
"dependencies": {
"@eslint/compat": "2.0.5",
"@eslint/compat": "2.1.0",
"@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-jsdoc": "62.9.0",
"eslint-plugin-mocha": "11.2.0",
"eslint-plugin-n": "17.24.0",
"eslint-plugin-jsdoc": "63.0.0",
"eslint-plugin-mocha": "11.3.0",
"eslint-plugin-n": "18.0.1",
"eslint-plugin-perfectionist": "5.9.0",
"eslint-plugin-promise": "7.3.0",
"eslint-plugin-unicorn": "64.0.0",
"typescript-eslint": "8.59.1"
"typescript-eslint": "8.60.0"
},
"peerDependencies": {
"eslint": "^9.0.0 || ^10.0.0"
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [6.0.4](https://github.com/appium/appium/compare/@appium/execute-driver-plugin@6.0.3...@appium/execute-driver-plugin@6.0.4) (2026-05-31)
**Note:** Version bump only for package @appium/execute-driver-plugin
## [6.0.3](https://github.com/appium/appium/compare/@appium/execute-driver-plugin@6.0.2...@appium/execute-driver-plugin@6.0.3) (2026-05-06)
**Note:** Version bump only for package @appium/execute-driver-plugin
@@ -1,7 +1,6 @@
import _ from 'lodash';
import vm from 'node:vm';
import {promisify} from 'node:util';
import {logger, util} from 'appium/support';
import {logger, util} from '@appium/support';
import type {DriverScriptMessageEvent, ScriptResult, RunScriptResult} from './types';
import {wrapHostBindingForVmContext} from './vm-host-binding';
@@ -21,7 +20,7 @@ export const MJSONWP_ELEMENT_KEY = 'ELEMENT';
*/
async function runScript(eventParams: DriverScriptMessageEvent): Promise<RunScriptResult> {
const {driverOpts, script, timeoutMs} = eventParams;
if (!_.isNumber(timeoutMs)) {
if (typeof timeoutMs !== 'number' || Number.isNaN(timeoutMs)) {
throw new TypeError('Timeout parameter must be a number');
}
@@ -95,7 +94,7 @@ function coerceScriptResult(obj: any): any {
let res: any;
// now we begin our recursive case options
if (_.isPlainObject(obj)) {
if (util.isPlainObject(obj)) {
// if we have an object, it's either an element object or something else
// webdriverio has no monadic object types other than element and driver,
// and we don't want to allow special casing return of driver
@@ -125,7 +124,7 @@ function coerceScriptResult(obj: any): any {
}
// also handle arrays
if (_.isArray(obj)) {
if (Array.isArray(obj)) {
return obj.map(coerceScriptResult);
}
@@ -154,8 +153,8 @@ async function main(eventParams: DriverScriptMessageEvent): Promise<void> {
}
// ensure we're running this script in IPC mode
if (require.main === module && _.isFunction(process.send)) {
send = promisify(process.send).bind(process);
if (require.main === module && typeof process.send === 'function') {
send = promisify(process.send).bind(process) as (res: ScriptResult) => Promise<void>;
log.info('Running driver execution in child process');
process.on('message', main);
}
+2 -3
View File
@@ -1,7 +1,6 @@
import {BasePlugin} from 'appium/plugin';
import _ from 'lodash';
import cp from 'node:child_process';
import {timing} from 'appium/support';
import {timing} from '@appium/support';
import type {ExternalDriver, NextPluginCallback, MethodMap, PluginCommand} from '@appium/types';
const FEAT_FLAG = 'execute_driver_script';
@@ -62,7 +61,7 @@ export class ExecuteDriverPlugin extends BasePlugin {
);
}
if (!_.isNumber(timeoutMs)) {
if (typeof timeoutMs !== 'number' || Number.isNaN(timeoutMs)) {
throw new TypeError('Timeout parameter must be a number');
}

Some files were not shown because too many files have changed in this diff Show More