Compare commits

...
Author SHA1 Message Date
github-actions 6815fbebac chore: publish
- appium@4.0.0-beta.3
 - @appium/base-driver@11.0.0-beta.2
 - @appium/base-plugin@4.0.0-beta.2
 - @appium/docutils@4.0.0-beta.2
 - @appium/driver-test-support@4.0.0-beta.2
 - @appium/execute-driver-plugin@8.0.0-beta.3
 - @appium/fake-driver@7.0.0-beta.3
 - @appium/fake-plugin@5.0.0-beta.3
 - @appium/http-server@1.0.0-beta.2
 - @appium/images-plugin@6.0.0-beta.3
 - @appium/logger@3.0.0-beta.1
 - @appium/opencv@6.0.0-beta.2
 - @appium/oxc-config@2.0.0-beta.1
 - @appium/plugin-test-support@4.0.0-beta.3
 - @appium/relaxed-caps-plugin@4.0.0-beta.3
 - @appium/schema@2.0.0-beta.1
 - @appium/semantic-release-config@2.0.0-beta.1
 - @appium/storage-plugin@3.0.0-beta.3
 - @appium/strongbox@3.0.0-beta.1
 - @appium/support@8.0.0-beta.2
 - @appium/tsconfig@2.0.0-beta.1
 - @appium/types@2.0.0-beta.2
 - @appium/universal-xml-plugin@4.0.0-beta.3
2026-10-05 09:57:28 +00:00
Cassian433andCassian433 b95aaac60b feat(universal-xml-plugin): add execute methods to toggle translation (#22862)
* feat(universal-xml-plugin): add execute methods to toggle translation

Translation could not be turned off during a session, so there was no
way to get the driver's original page source or to run an XPath query
against the original node names while the plugin was active.

This adds two execute methods:

- `universalXml: setEnabled` turns translation off or on for the
  current session. While it's off, getPageSource and findElement(s)
  are passed on unchanged.
- `universalXml: isEnabled` returns the current state.

Translation stays on by default, so existing sessions behave as
before. The context API is not touched, so drivers' assumptions about
the current context are unaffected.

Refs #21630

* chore(universal-xml-plugin): add jsdoc to public methods and mark unused args with void

---------

Co-authored-by: Cassian433 <262026629+Cassian433@users.noreply.github.com>
2026-10-05 17:00:54 +09:00
Kazuaki Matsuo 83122525db fix(appium): import extensions through escaped file URLs (#22884)
* fix(appium): import extensions through escaped file URLs

* docs(appium): clarify why extension imports still require file URLs

* Enhance comments for schemaPath handling

Add comments explaining file URL requirements for Windows and POSIX paths.
2026-10-05 15:27:08 +09:00
Mohamed Mostafa c159d1925c fix(base-driver): remove incomplete app downloads (#22878) 2026-10-05 15:24:38 +09:00
Kazuaki Matsuo b1ec406fd3 docs: document contributor style conventions for agents (#22879)
* feat(oxc-config): restore class member ordering through JS plugins

* docs: document class method ordering for agents and contributors

* docs: keep ordering rules in the contributor style guide

* docs: simplify the agent style guide reference

* docs: add JSDoc and module ordering conventions
2026-10-04 23:10:40 -07:00
Kazuaki Matsuo c5f4217806 fix(base-driver): decode credentials in app download URLs (#22887) 2026-10-05 15:00:38 +09:00
Kazuaki Matsuo 3188c9fbab fix(appium): resolve default extension export conditions (#22885) 2026-10-05 15:00:01 +09:00
Kazuaki Matsuo b791915ffc fix(base-driver): track and remove BiDi subscriptions by ID (#22876)
* fix(base-driver): track and remove BiDi subscriptions by ID

* refactor(base-driver): organize BiDi subscription helpers

* fix(base-driver): address BiDi subscription review feedback

* test(base-driver): cover BiDi unsubscribe review cases

* docs(appium): document BiDi subscription IDs
2026-10-05 14:58:56 +09:00
Kazuaki Matsuo 95c04611e7 fix(appium): apply initial settings from selected capabilities (#22882)
* fix(appium): apply initial settings from selected capabilities

* test(appium): keep settings regressions separate from timeout tests
2026-10-05 14:55:58 +09:00
Kazuaki Matsuo e37f1c1583 fix(appium): start idle timeout after session initialization (#22881) 2026-10-05 14:29:32 +09:00
Kazuaki Matsuo 879873bad5 fix(appium): match global and module BiDi event subscriptions (#22883) 2026-10-05 01:54:47 +09:00
Kazuaki Matsuo 6c9c930cbe fix(support): preserve symlinks inside moved directories (#22880) 2026-10-05 01:54:06 +09:00
Kazuaki Matsuo 4de1a3e62c fix(support): accept transparently decompressed downloads (#22886) 2026-10-05 01:53:47 +09:00
Kazuaki Matsuo ae8a27e02b fix(base-driver): reject queued commands when their session ends (#22874)
* fix(base-driver): reject queued commands when their session ends

* refactor(base-driver): cancel queued commands with AbortController
2026-10-05 01:12:52 +09:00
Kazuaki Matsuo b9c020c6eb fix(appium): explain manual recovery after manifest lock timeouts (#22875)
* fix(appium): recover abandoned manifest locks with heartbeats

* fix(appium): retry released manifest locks promptly

* fix(appium): explain manual manifest lock recovery without new dependencies

* test(appium): keep manifest lock mocks compatible with Node 22
2026-10-05 01:12:36 +09:00
Kazuaki Matsuo e391bb249f fix: use native ESM entrypoint detection (#22868) 2026-10-04 17:39:35 +02:00
Kazuaki Matsuo f24e7046eb fix(appium): roll back sessions when initialization fails (#22873) 2026-10-05 00:21:14 +09:00
Kazuaki Matsuo b077efd67d fix(base-driver): reject unsupported BiDi user-context scopes (#22877) 2026-10-04 10:18:50 +02:00
Kazuaki Matsuo 3875344e11 fix(appium): bracket IPv6 hosts in BiDi websocket URLs (#22872) 2026-10-04 10:02:13 +02:00
Kazuaki Matsuo 11d9904166 fix(appium): detect project homes with prerelease dependencies (#22871) 2026-10-04 10:01:42 +02:00
Kazuaki Matsuo 1b7d1e0eb7 fix(base-driver): validate reset options before creating session state (#22870) 2026-10-04 10:00:44 +02:00
Kazuaki Matsuo 14c42f791c fix(base-driver): return protocol errors for null rejections (#22869) 2026-10-04 09:59:50 +02:00
Kazuaki Matsuo 63e9aa4f57 fix(base-driver): declare the path-to-regexp runtime dependency (#22865) 2026-10-04 09:59:08 +02:00
Kazuaki Matsuo 9de70be6e2 fix(base-driver): keep the idle timer off while commands execute (#22867) 2026-10-04 09:55:12 +02:00
Kazuaki Matsuo c78cfa405f fix(types): align the logger peer dependency with Appium 4 (#22866) 2026-10-04 09:54:12 +02:00
Kazuaki Matsuo 29a60d6e93 fix(support): preserve directories on failed downloads (#22864) 2026-10-04 09:53:22 +02:00
Mohamed Mostafa 1b852557a3 fix(storage-plugin): reject storage file names that start with a dot (#22863) 2026-10-04 08:57:43 +02:00
Mohamed Mostafa 318329fba7 fix(base-driver): report noReset and fullReset together as session not created (#22861) 2026-10-04 15:53:00 +09:00
Edgars Eglītis 6d1f28ed05 fix(base-driver): reject empty array in capabilities.firstMatch (#22860)
BREAKING CHANGE: when creating a new session, capabilities.firstMatch can no longer be set to an empty array, as per the WebDriver spec
2026-10-03 20:54:41 +03:00
Mohamed Mostafa 00547fe9f6 refactor(support): share websocket close codes from net (#22855) 2026-10-03 14:43:16 +02:00
Mohamed Mostafa 3cdc69b198 fix(appium): close bidi sockets when a session ends unexpectedly (#22854) 2026-10-03 14:42:27 +02:00
Mykola Mokhnach 53b36283f3 chore: use version ranges instead of exact pins (#22846) 2026-10-03 14:41:42 +02:00
Edgars Eglītis 5cc6041ce9 chore: remove placeholder doctor script (#22841) 2026-10-02 12:06:06 +03:00
Edgars Eglītis 8fbdd42eab chore: add automatic label for http-server package (#22842) 2026-10-01 16:02:11 +03:00
Edgars Eglītis ebcac6a7e1 chore: replace ora with yocto-spinner (#22840) 2026-09-30 21:53:21 +03:00
Mohamed Mostafa 66ab396a71 fix(appium): do not crash when a proxied bidi socket closes without a sendable code (#22835) 2026-09-30 08:13:56 +02:00
Mohamed Mostafa 15ecc718c4 fix(support): do not leave a file behind when downloadFile fails (#22836) 2026-09-29 22:16:19 +02:00
Mohamed Mostafa f330716793 fix(base-driver): do not arm the idle timer while a command is still running (#22828) 2026-09-29 19:18:15 +02:00
Mykola Mokhnach e033851cdf refactor(support): vendor klaw as internal fs walker (#22826) 2026-09-28 08:06:55 +02:00
Mykola Mokhnach 6b0938d3ac refactor: drop type-fest dependency across the monorepo (#22825) 2026-09-27 20:18:03 +02:00
Mykola Mokhnach bb51494f67 chore: remove unused jsftp, serve-favicon, and mjpeg dev dependencies (#22827) 2026-09-27 20:14:29 +02:00
github-actions d8370ccf19 chore: publish
- appium@4.0.0-beta.2
 - @appium/base-driver@11.0.0-beta.1
 - @appium/base-plugin@4.0.0-beta.1
 - @appium/docutils@4.0.0-beta.1
 - @appium/driver-test-support@4.0.0-beta.1
 - @appium/execute-driver-plugin@8.0.0-beta.2
 - @appium/fake-driver@7.0.0-beta.2
 - @appium/fake-plugin@5.0.0-beta.2
 - @appium/http-server@1.0.0-beta.1
 - @appium/images-plugin@6.0.0-beta.2
 - @appium/opencv@6.0.0-beta.1
 - @appium/plugin-test-support@4.0.0-beta.2
 - @appium/relaxed-caps-plugin@4.0.0-beta.2
 - @appium/storage-plugin@3.0.0-beta.2
 - @appium/support@8.0.0-beta.1
 - @appium/types@2.0.0-beta.1
 - @appium/universal-xml-plugin@4.0.0-beta.2
2026-09-27 07:38:49 +00:00
Kazuaki Matsuo fb89bb7423 fix(support): preserve exclamation marks with apostrophes (#22816) (#22822)
* fix(support): preserve exclamation marks with apostrophes

* test(support): use async shell execution and document workaround

(cherry picked from commit 359ef4ba89)
2026-09-27 03:07:32 +09:00
Mykola Mokhnach dc0568bc65 fix(appium): release the manifest lock before errAndQuit exits (#22821) 2026-09-26 20:05:48 +02:00
Mohamed Mostafa f313306848 fix(base-driver): keep the default idle timeout when newCommandTimeout is null or blank (#22815) 2026-09-26 13:52:08 +02:00
Mykola Mokhnach b7356ee53e feat(appium): accept package names for installed extension commands (#22747) (#22812) 2026-09-24 23:06:25 +02:00
Mykola Mokhnach ad8e7d56f8 chore: backport recent fixes from master (#22810) 2026-09-24 19:16:20 +02:00
Mykola Mokhnach 7a79dc208c refactor(base-driver): vendor Levenshtein distance and drop http-status-codes dep (#22805) 2026-09-22 22:16:34 +02:00
Mykola Mokhnach 822e4d19d5 refactor(http-server): move network/listener-address helpers out of appium (#22804) 2026-09-22 21:33:05 +02:00
Mykola Mokhnach 2596da02d2 fix(base-driver): carry proxy req via AsyncLocalStorage instead of args (#22799) 2026-09-22 18:28:20 +02:00
Mykola Mokhnach 225a1c6d9d refactor(base-driver)!: extract the HTTP/Express server into @appium/http-server (#22800)
BREAKING CHANGE: @appium/base-driver no longer exports server, normalizeBasePath, configureServer, ServerOpts, ConfigureServerOpts, ConfigureHttpOpts, or StartServerOpts, and no longer depends on express/morgan/body-parser/ method-override/path-to-regexp. Use the new @appium/http-server package instead.

BREAKING CHANGE: appium/driver.js and driver.d.ts no longer re-export server/normalizeBasePath/configureServer/ServerOpts (or their types), and no longer use a blanket export *. Import from @appium/http-server directly if you were relying on this undocumented path.
2026-09-22 07:44:13 +02:00
Mykola Mokhnach 22a00c7a55 feat(base-driver)!: exempt getStatus from the commands queue by default (#22796) 2026-09-21 17:14:52 +02:00
Mykola Mokhnach 09a04f4883 fix(support): narrow fs.glob overload for default withFileTypes/lazy (#22795) 2026-09-20 20:49:25 +02:00
Mykola Mokhnach 4f70ff136f docs: add Appium 3 to 4 migration guide (#22790) 2026-09-20 16:25:16 +02:00
github-actions c2e53e15e5 chore: publish
- appium@4.0.0-beta.1
 - @appium/execute-driver-plugin@8.0.0-beta.1
 - @appium/fake-driver@7.0.0-beta.1
 - @appium/fake-plugin@5.0.0-beta.1
 - @appium/images-plugin@6.0.0-beta.1
 - @appium/plugin-test-support@4.0.0-beta.1
 - @appium/relaxed-caps-plugin@4.0.0-beta.1
 - @appium/storage-plugin@3.0.0-beta.1
 - @appium/universal-xml-plugin@4.0.0-beta.1
2026-09-19 21:26:08 +00:00
Mykola Mokhnach b09efc31c6 fix(deps): catch up peerDependencies to published beta.0; drop bootstrap_packages (#22793) 2026-09-19 23:21:31 +02:00
194 changed files with 4932 additions and 1198 deletions
+5
View File
@@ -43,6 +43,11 @@ labels:
matcher:
files: ['packages/appium/**']
- label: '@appium/http-server'
sync: true
matcher:
files: ['packages/http-server/**']
- label: '@appium/base-driver'
sync: true
matcher:
+1 -30
View File
@@ -13,11 +13,6 @@ on:
required: true
type: boolean
default: false
bootstrap_packages:
description: 'One-time beta-branch bootstrap only: comma-separated package names (e.g. @appium/base-driver,@appium/support) to force-bump to their next-major beta (premajor --preid beta), bypassing conventional-commit detection. Leave empty for a normal release.'
required: false
type: string
default: ''
allow_legacy_peer_deps:
description: 'Beta branch only: pass --legacy-peer-deps to lerna''s internal npm calls, for when a dependent''s peerDependencies range still lags a just-bumped major (causes ERESOLVE otherwise). Leave off unless you hit that failure.'
required: true
@@ -63,7 +58,6 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
RECOVER_MISSING_ONLY: ${{ inputs.recover_missing_only }}
BOOTSTRAP_PACKAGES: ${{ inputs.bootstrap_packages }}
ALLOW_LEGACY_PEER_DEPS: ${{ inputs.allow_legacy_peer_deps }}
run: |
set -euo pipefail
@@ -87,29 +81,6 @@ jobs:
DIST_TAG_FLAGS="--dist-tag beta"
fi
# One-time bootstrap for packages just starting their beta train: force them
# straight to <next-major>.0.0-beta.0 instead of waiting for/relying on a commit that
# happens to be marked as breaking. Not part of the normal release flow below - this
# exits early on its own.
if [ -n "$BOOTSTRAP_PACKAGES" ]; then
if [ "${{ github.ref_name }}" != "$BETA_BRANCH" ]; then
echo "bootstrap_packages is only meaningful on the $BETA_BRANCH branch" >&2
exit 1
fi
if [ "$RECOVER_MISSING_ONLY" = "true" ]; then
echo "bootstrap_packages cannot be combined with recover_missing_only" >&2
exit 1
fi
echo "Bootstrapping ${BOOTSTRAP_PACKAGES} to their next-major beta (premajor --preid beta), bypassing conventional-commit detection"
if [ "$DRY_RUN" = "true" ]; then
printf "n\n" | npx lerna version premajor --preid beta --exact --no-git-tag-version --no-push --force-publish="$BOOTSTRAP_PACKAGES" $LEGACY_PEER_DEPS_FLAGS
else
npx lerna publish premajor --preid beta --exact --force-publish="$BOOTSTRAP_PACKAGES" --dist-tag beta --yes $LEGACY_PEER_DEPS_FLAGS
fi
exit 0
fi
# Prints "<pkg_name>@<pkg_version>" to stdout for each non-private workspace package
# whose current package.json version is not yet on npm.
find_missing_packages() {
@@ -209,7 +180,7 @@ jobs:
if [ "$DRY_RUN" = "true" ]; then
echo "Running lerna publish in dry-run mode"
printf "n\n" | npx lerna version --exact --no-git-tag-version --no-push $BETA_FLAGS
printf "n\n" | npx lerna version --no-git-tag-version --no-push $BETA_FLAGS
else
echo "Running lerna publish for real"
set +e
+1 -1
View File
@@ -47,4 +47,4 @@ jobs:
if [ "${{ github.ref_name }}" = "$BETA_BRANCH" ]; then
BETA_FLAGS="--conventional-prerelease --preid beta"
fi
npx lerna version --exact --yes $BETA_FLAGS
npx lerna version --yes $BETA_FLAGS
+1 -3
View File
@@ -6,14 +6,12 @@ on:
paths:
- '.github/workflows/renovate.yml' # this file
- '.renovaterc.json'
- 'renovate/default.json'
pull_request:
branches:
- master
paths:
- '.github/workflows/renovate.yml' # this file
- '.renovaterc.json'
- 'renovate/default.json'
permissions:
contents: read
@@ -27,4 +25,4 @@ jobs:
- name: Validate Renovate Config
uses: rinchsan/renovate-config-validator@v0.2.1
with:
pattern: '{.renovaterc.json,renovate/default.json}'
pattern: '.renovaterc.json'
-1
View File
@@ -1,4 +1,3 @@
# Do not disable package-lock. Disabling package-lock also disables npm-shrinkwrap
package-lock=true
save-exact=true
foreground-scripts=true
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>appium/appium//renovate/default"],
"extends": ["github>appium/appium-workflows//renovate/default"],
"schedule": ["after 10pm", "before 5:00am"],
"timezone": "America/Vancouver"
}
+4
View File
@@ -0,0 +1,4 @@
# Repository guidance
Before changing code, read and follow the
[contributor style guide](packages/appium/docs/en/contributing/index.md#code-style).
+1 -5
View File
@@ -2,9 +2,6 @@
"$schema": "https://json.schemastore.org/lerna",
"changelogPreset": "conventional-changelog-conventionalcommits",
"command": {
"add": {
"exact": true
},
"publish": {
"message": "chore: publish"
},
@@ -14,8 +11,7 @@
"version": {
"conventionalCommits": true,
"createRelease": "github",
"allowBranch": ["master", "appium4"],
"exact": true
"allowBranch": ["master", "appium4"]
}
},
"ignoreChanges": ["**/test/**", "**/*.md"],
+197 -307
View File
File diff suppressed because it is too large Load Diff
+2 -12
View File
@@ -48,7 +48,6 @@
"docs": "npm run -w packages/appium build:docs",
"docs:preview": "npm run -w packages/appium build:docs:preview",
"docs:publish": "npm run -w packages/appium publish:docs",
"doctor": "node ./scripts/doctor-stub.mjs",
"install-docs-deps": "npm run -w packages/appium install-docs-deps",
"format": "oxfmt -c oxfmt.config.mjs .",
"format:check": "oxfmt -c oxfmt.config.mjs --check .",
@@ -62,7 +61,7 @@
"rebuild": "run-s clean build",
"reinstall": "run-s clean clean:deps ci",
"reinstall:angry": "npm run clean:dangerously && npm ci",
"release": "lerna publish --exact",
"release": "lerna publish",
"stage-synced-pkgs": "git add -A packages/appium/README.md \"packages/*/package.json\"",
"start": "appium",
"sync-pkgs": "node ./scripts/sync-monorepo-packages.mjs",
@@ -80,15 +79,11 @@
"zip-and-upload": "run-s zip upload"
},
"devDependencies": {
"@appium/oxc-config": "1.0.0",
"@appium/oxc-config": "2.0.0-beta.0",
"@tsconfig/node22": "22.0.0",
"@types/async-lock": "1.4.2",
"@types/content-disposition": "0.5.9",
"@types/express": "5.0.6",
"@types/finalhandler": "1.2.4",
"@types/jsftp": "2.1.5",
"@types/json-schema": "7.0.15",
"@types/klaw": "3.0.7",
"@types/lockfile": "1.0.4",
"@types/method-override": "3.0.0",
"@types/mime-types": "3.0.1",
@@ -97,7 +92,6 @@
"@types/picomatch": "4.0.3",
"@types/pluralize": "0.0.33",
"@types/semver": "7.8.0",
"@types/serve-favicon": "2.5.7",
"@types/shell-quote": "1.7.5",
"@types/sinon": "22.0.0",
"@types/which": "3.0.4",
@@ -107,14 +101,10 @@
"conventional-changelog-conventionalcommits": "9.3.1",
"cpy-cli": "7.0.0",
"cross-env": "10.1.0",
"finalhandler": "2.1.1",
"json-schema-to-typescript": "15.0.4",
"lerna": "10.0.1",
"mjpeg-consumer": "2.0.0",
"mjpeg-server": "0.3.1",
"npm-run-all2": "9.0.3",
"rimraf": "6.1.3",
"serve-static": "2.2.1",
"sinon": "22.1.0",
"teen_process": "4.2.1",
"tsd": "0.33.0",
+52
View File
@@ -3,6 +3,58 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [4.0.0-beta.3](https://github.com/appium/appium/compare/appium@4.0.0-beta.2...appium@4.0.0-beta.3) (2026-10-05)
### Features
* **universal-xml-plugin:** add execute methods to toggle translation ([#22862](https://github.com/appium/appium/issues/22862)) ([b95aaac](https://github.com/appium/appium/commit/b95aaac60bed065ce0a0d1e4f7baf9c5e60572b1)), closes [#21630](https://github.com/appium/appium/issues/21630)
### Bug Fixes
* **appium:** apply initial settings from selected capabilities ([#22882](https://github.com/appium/appium/issues/22882)) ([95c0461](https://github.com/appium/appium/commit/95c04611e7d1cd8acfa4c6ce51276899938588d3))
* **appium:** bracket IPv6 hosts in BiDi websocket URLs ([#22872](https://github.com/appium/appium/issues/22872)) ([3875344](https://github.com/appium/appium/commit/3875344e111dc23e4c99310ad918b769dbb38927))
* **appium:** close bidi sockets when a session ends unexpectedly ([#22854](https://github.com/appium/appium/issues/22854)) ([3cdc69b](https://github.com/appium/appium/commit/3cdc69b198e85607b4f35a5b3f57b542b0ae9b80))
* **appium:** detect project homes with prerelease dependencies ([#22871](https://github.com/appium/appium/issues/22871)) ([11d9904](https://github.com/appium/appium/commit/11d9904166c9e692e6fa8491acfce4ac892c1a8e))
* **appium:** do not crash when a proxied bidi socket closes without a sendable code ([#22835](https://github.com/appium/appium/issues/22835)) ([66ab396](https://github.com/appium/appium/commit/66ab396a71c419f1fb456b8a1ddf23bc3491baf8))
* **appium:** explain manual recovery after manifest lock timeouts ([#22875](https://github.com/appium/appium/issues/22875)) ([b9c020c](https://github.com/appium/appium/commit/b9c020c6ebf88643ffe6b6d5e302b43673bbb028))
* **appium:** import extensions through escaped file URLs ([#22884](https://github.com/appium/appium/issues/22884)) ([8312252](https://github.com/appium/appium/commit/83122525db72f2d6ff06e27cd3da5269318be3a8))
* **appium:** match global and module BiDi event subscriptions ([#22883](https://github.com/appium/appium/issues/22883)) ([879873b](https://github.com/appium/appium/commit/879873bad5669c9b264f3fcb60d05562f6ca3b1e))
* **appium:** resolve default extension export conditions ([#22885](https://github.com/appium/appium/issues/22885)) ([3188c9f](https://github.com/appium/appium/commit/3188c9fbab17c16192a6e75ff62f69f4d844be8f))
* **appium:** roll back sessions when initialization fails ([#22873](https://github.com/appium/appium/issues/22873)) ([f24e704](https://github.com/appium/appium/commit/f24e7046eb2815822f655a644aee9786d90fd4e8))
* **appium:** start idle timeout after session initialization ([#22881](https://github.com/appium/appium/issues/22881)) ([e37f1c1](https://github.com/appium/appium/commit/e37f1c158394617bcaa474da25f1650b9b24ebd1))
* **base-driver:** track and remove BiDi subscriptions by ID ([#22876](https://github.com/appium/appium/issues/22876)) ([b791915](https://github.com/appium/appium/commit/b791915ffcf322e1cc84dbde606be55d34f020b3))
* use native ESM entrypoint detection ([#22868](https://github.com/appium/appium/issues/22868)) ([e391bb2](https://github.com/appium/appium/commit/e391bb249fec5f700a6d63a747ff921a80689fb5))
## [4.0.0-beta.2](https://github.com/appium/appium/compare/appium@4.0.0-beta.1...appium@4.0.0-beta.2) (2026-09-27)
### ⚠ BREAKING CHANGES
* **base-driver:** @appium/base-driver no longer exports server, normalizeBasePath, configureServer, ServerOpts, ConfigureServerOpts, ConfigureHttpOpts, or StartServerOpts, and no longer depends on express/morgan/body-parser/ method-override/path-to-regexp. Use the new @appium/http-server package instead.
* **base-driver:** appium/driver.js and driver.d.ts no longer re-export server/normalizeBasePath/configureServer/ServerOpts (or their types), and no longer use a blanket export *. Import from @appium/http-server directly if you were relying on this undocumented path.
### Features
* **appium:** accept package names for installed extension commands ([#22747](https://github.com/appium/appium/issues/22747)) ([#22812](https://github.com/appium/appium/issues/22812)) ([b7356ee](https://github.com/appium/appium/commit/b7356ee53eec3e322707eef0d2bd37976a47cfd2))
### Bug Fixes
* **appium:** release the manifest lock before errAndQuit exits ([#22821](https://github.com/appium/appium/issues/22821)) ([dc0568b](https://github.com/appium/appium/commit/dc0568bc658961f5d6a28fb955d8092d51cf9523))
* **base-driver:** carry proxy req via AsyncLocalStorage instead of args ([#22799](https://github.com/appium/appium/issues/22799)) ([2596da0](https://github.com/appium/appium/commit/2596da02d201d7e5fbfd24cf9dd2801302f8abc7))
* **support:** narrow fs.glob overload for default withFileTypes/lazy ([#22795](https://github.com/appium/appium/issues/22795)) ([09a04f4](https://github.com/appium/appium/commit/09a04f48831e17b9b7f3b8dc0a72f0df5096ae51))
### Code Refactoring
* **base-driver:** extract the HTTP/Express server into @appium/http-server ([#22800](https://github.com/appium/appium/issues/22800)) ([225a1c6](https://github.com/appium/appium/commit/225a1c6d9d24fa2696699d4b61436a49b847d666))
## [4.0.0-beta.1](https://github.com/appium/appium/compare/appium@4.0.0-beta.0...appium@4.0.0-beta.1) (2026-09-19)
### Bug Fixes
* **deps:** catch up peerDependencies to published beta.0; drop bootstrap_packages ([#22793](https://github.com/appium/appium/issues/22793)) ([b09efc3](https://github.com/appium/appium/commit/b09efc31c6967bbcf2c4314db9d2a0b6454fa8fb))
## [4.0.0-beta.0](https://github.com/appium/appium/compare/appium@3.7.0...appium@4.0.0-beta.0) (2026-09-19)
### ⚠ BREAKING CHANGES
@@ -103,6 +103,27 @@ export APPIUM_WORKSPACE=@appium/base-driver
npm run test:unit -w $APPIUM_WORKSPACE
```
### Code Style
#### JSDoc
Add concise JSDoc to all public class methods and exported functions.
#### Module Ordering
Place exported functions and classes before non-exported functions and classes in each module.
This rule does not apply to constants or variables.
#### Class Member Ordering
When adding or moving class methods, follow these conventions:
- Order methods by visibility: public → protected → private. Methods without an explicit visibility
modifier belong to the public group.
- Place private helper methods at the end of the class.
- Preserve existing order within each visibility group.
- Before finishing, check the ordering of methods you added or moved.
### Documentation
The documentation for this project is [available in the project repository itself](https://github.com/appium/appium/tree/master/packages/appium/docs).
@@ -476,7 +476,7 @@ capability.
You probably don't normally need to update the Appium server object (which is an
[Express](https://expressjs.com/) server having already been
[configured](https://github.com/appium/appium/blob/master/packages/base-driver/lib/express/server.js)
[configured](https://github.com/appium/appium/blob/master/packages/http-server/lib/server.ts)
in a variety of ways). But, for example, you could add new routes to the server to support your
plugin's requirements. To update the server you must implement the `static async updateServer`
method in your class. This method takes three parameters:
@@ -0,0 +1,294 @@
---
title: Migrating to Appium 4
---
<style>
.md-typeset .grid {
grid-template-columns: repeat(auto-fit,minmax(min(100%,11rem),1fr));
}
</style>
This document is a guide for those who are using Appium 3 and would like to upgrade to Appium 4.
It contains a list of breaking changes, as well as suggestions for handling them.
Appium 4 focuses on modernizing the codebase (Node.js requirements, ESM-only packages, dropping
long-deprecated protocol support and APIs) rather than introducing a large number of new
user-facing features. Most Appium users only need to check the [Node.js version](#node-2222-required)
section below; the remaining breaking changes mostly affect driver and plugin developers, or users
of specific removed flags/endpoints.
## Installation
Appium 4 is currently only available as a beta release, published under the `beta` npm dist-tag.
The installation method otherwise remains the same as for Appium 3. If you want to upgrade, you
can simply install Appium 4 on top of your existing installation:
```bash
# optional: `appium setup reset`
npm install -g appium@beta
```
## Breaking Changes
### Node 22.22+ Required
Appium 3 required Node `^20.19.0 || ^22.12.0 || >=24.0.0`. Appium 4 drops Node 20 support and
bumps the minimum required version to `^22.22.2 || ^24.15.0 || >=26.0.0`.
!!! info "Actions Needed"
Upgrade Node.js to `v22.22.2`/`v24.15.0` or newer
### All Packages Are Now ESM-Only
Every `@appium/*` package (and `appium` itself) is now published as `"type": "module"` with no
CommonJS build. `require()` no longer works for these packages. This affects anyone importing
Appium or a driver/plugin package directly from Node.js code, not just driver/plugin developers.
!!! info "Actions Needed"
Import Appium packages with ESM `import` syntax, or use dynamic `import()` from CommonJS code
### `--nodeconfig` Flag Removed
Support for registering with a legacy Selenium Grid 3 hub has been removed, along with the
`--nodeconfig` server argument and the `server.nodeconfig` config file property.
!!! info "Actions Needed"
If you relied on Grid 3 registration, use the [Selenium Grid 4 relay feature](./grid.md) instead
### `--allow-cors` Flag Removed
CORS is now gated behind the same [insecure feature](./security.md) mechanism used for other
server-level features (e.g. `session_discovery`), instead of its own dedicated flag.
!!! info "Actions Needed"
Replace `--allow-cors` with `--allow-insecure=*:cors` (or enable it via `--relaxed-security`)
### Extension Auto-Install on `npm install` Removed
The `npm install -g appium --drivers=... --plugins=...` postinstall hook that automatically
installed extensions has been removed, since npm postinstall hooks are a supply-chain security
risk and newer npm versions no longer support passing custom arguments to them.
!!! info "Actions Needed"
Use `appium driver install`/`appium plugin install`, or declare extensions in your project's
`package.json`, instead of `--drivers`/`--plugins` install-time arguments
### Default Log Level Changed to `info`
The default `--log-level` changed from `debug` to `info`. Debug-level logging is very verbose and
mostly only useful for troubleshooting.
!!! info "Actions Needed"
Pass `--log-level debug` explicitly if you rely on the previous verbosity
### CLI Output Streams Split
Appium's CLI commands (`driver`/`plugin install|list|update`, `doctor`, etc.) used to write all
non-JSON output to `STDERR`. Human-readable output (`log`/`ok`/`info`/`warn`) now goes to
`STDOUT`; only `error` output still goes to `STDERR`. The main server's log output (Winston-based)
is unaffected, since it already split streams this way.
!!! info "Actions Needed"
If you have scripts that capture Appium CLI output, make sure they read from the correct stream
### TLS Server No Longer Negotiates SPDY/HTTP2
Appium's TLS-enabled server used the unmaintained `spdy` package, which allowed negotiating
SPDY/HTTP2 over ALPN. It has been replaced with Node's native `https` module, which only serves
plain HTTPS (HTTP/1.1).
!!! info "Actions Needed"
Clients relying on SPDY or HTTP/2 multiplexing against Appium's TLS listener must switch to
plain HTTPS (HTTP/1.1)
### Timeout Errors Now Return HTTP 500
Per the W3C WebDriver standard, `TimeoutError` and `ScriptTimeoutError` now map to HTTP `500`
instead of `408`. The stale `408` mapping could cause keep-alive clients/proxies to auto-retry an
already-executed timed-out command.
!!! info "Actions Needed"
None, unless your code inspects the raw HTTP status code of a timeout response
### Timeouts Endpoint Aligned With W3C Standard
`GET/SET /timeouts` is now closer to [the W3C standard](https://w3c.github.io/webdriver/#timeouts):
* `POST /session/:sessionId/timeouts` now also accepts the standard `command` parameter; the
legacy `type`/`ms` parameters are deprecated
* `POST /session/:sessionId/timeouts` no longer errors out when none of `script`/`pageLoad`/`implicit`
are provided
* `GET /session/:sessionId/timeouts` now also returns `script` and `pageLoad`, in addition to the
existing `implicit` and Appium-specific `command` values
* Driver-level `timeouts()`/`ISessionHandler` type declarations narrow `implicit` to a `number`,
matching `script`/`pageLoad` (the wire endpoint still coerces numeric strings at runtime)
!!! info "Actions Needed"
Update any code that sends the `type`/`ms` timeout parameters or a `string` value for `implicit`
### BiDi Commands No Longer Accept Stray `sessionId`/`id` Params
Unlike HTTP endpoints, WebDriver BiDi commands have no URL-derived session/element id, so a stray
`sessionId` or `id` key inside BiDi command params is no longer silently accepted; it is now
validated like any other unknown parameter.
!!! info "Actions Needed"
Remove `sessionId`/`id` from the parameters of any custom BiDi command invocation
## Endpoint Changes
The following endpoints have been removed:
* `GET/POST /session/:sessionId/ime/*` (all IME endpoints, legacy JSONWP)
* `GET /session/:sessionId/location`, `POST /session/:sessionId/location` (legacy geolocation, legacy JSONWP)
* `GET /session/:sessionId/network_connection`, `POST /session/:sessionId/network_connection` (legacy MJSONWP)
* `POST /session/:sessionId/receive_async_response` (legacy JSONWP)
* `GET /session/:sessionId/element/:elementId` (undocumented legacy JSONWP stub)
* `GET /session/:sessionId` (deprecated `getSession`; see [below](#getsession-command-removed) for
its replacement)
The `orientation`, `context`/`contexts`, and `rotation` deprecated endpoints are unaffected and
remain available.
## Changes for Driver and Plugin Developers
The following changes primarily affect authors of Appium drivers, plugins, or tools that import
Appium's internal packages directly (`@appium/base-driver`, `@appium/base-plugin`,
`@appium/support`, `@appium/logger`). They do not affect most Appium end users.
### Remaining Legacy (M)JSONWP Support Removed From `@appium/base-driver`
Beyond the removed endpoints above, the internal machinery that translated between the legacy
(M)JSONWP protocol and W3C WebDriver has been removed:
* `ProtocolError` instances no longer carry a `jsonwpCode` property, and error responses no longer
include a legacy numeric `status` code (`errorFromMJSONWPStatusCode`, aliased as `errorFromCode`,
has been removed; use `errorFromW3CJsonCode`)
* Responses no longer duplicate element references under the legacy `ELEMENT` key; only the W3C
`element-6066-11e4-a52e-4f735466cecf` key is present
* `WebDriverProxy` (formerly `JWProxy`, now removed as a name) no longer converts responses from a
downstream automation server speaking legacy MJSONWP; downstream servers must speak W3C WebDriver
* `determineProtocol`, `DriverCore#setProtocolMJSONWP()`, and `DriverCore#isMjsonwpProtocol()` have
been removed; drivers can no longer produce the legacy `{sessionId, status, value}` response shape
* `@appium/base-driver` no longer exports `statusCodes`/`getSummaryByCode` (the `jsonwp-status`
module was removed)
!!! info "Actions Needed"
Ensure any downstream automation server a driver proxies to (via `WebDriverProxy`) speaks pure
W3C WebDriver; update code referencing the removed exports to their W3C equivalents
### `@appium/base-driver` Default Export Removed
`BaseDriver` is no longer exported as `@appium/base-driver`'s default export, and
`basedriver/helpers.js`'s default export has been removed as well. `BaseDriver`'s constructor no
longer takes command-mixin machinery into account, and the unused `BaseDriver.reset()` method has
been removed.
!!! info "Actions Needed"
Replace `import BaseDriver from '@appium/base-driver'` with `import {BaseDriver} from '@appium/base-driver'`
### `@appium/base-plugin` Default Export and Legacy Logger Removed
`BasePlugin` is no longer the default export of `@appium/base-plugin`. `Plugin#logger` has been
removed in favor of `Plugin#log`, and `Plugin#name`/`Plugin#cliArgs` are now read-only.
!!! info "Actions Needed"
* Replace `import BasePlugin from '@appium/base-plugin'` with `import {BasePlugin} from '@appium/base-plugin'`
* Replace `this.logger` with `this.log` in plugin code
### `@appium/logger` Default Export and Dead APIs Removed
`@appium/logger` and `@appium/support` no longer export a default `log`. `AppiumLogger#errorAndThrow`
has been removed in favor of `errorWithException`. The long-disabled, no-op progress/unicode
methods (`enableProgress`, `disableProgress`, `progressEnabled`, `enableUnicode`, `disableUnicode`)
have also been removed.
!!! info "Actions Needed"
* Import the named `log` export instead of the default export
* Replace `errorAndThrow` calls with `errorWithException`
### `driverData` Mechanism Removed
The deprecated `driverData` mechanism (`Core.driverData`, the `driverData` parameter on
`createSession`/`deleteSession`, and the `DriverData` type) has been removed, superseded by
`IAppiumIpc`.
!!! info "Actions Needed"
Drivers overriding `get driverData()` or relying on it for cross-session coordination need to
migrate to `IAppiumIpc`
### `createSession` Multi-Argument Overload Removed
`createSession` no longer accepts the legacy multi-argument form
(`createSession(jwpCaps, reqCaps, w3cCaps)`), a holdover from JSONWP. Only the single
`w3cCapabilities` argument is supported now.
!!! info "Actions Needed"
Update any driver or plugin overriding `createSession` to use the single-argument signature
### `getSession` Command Removed
`BaseDriver.getSession` and `ISessionHandler.getSession` have been removed (see [Endpoint
Changes](#endpoint-changes) for the removed route), along with the `appium:eventTimings`
capability that controlled whether its response included event history.
!!! info "Actions Needed"
Use `GET /session/:sessionId/appium/capabilities` to retrieve session capabilities, and
`POST /session/:sessionId/appium/events` to retrieve event history (now returned unconditionally)
### `@appium/support` Deprecated APIs Removed
The following `@appium/support` exports have been removed: `process` (`getProcessIds`,
`killProcess`), `mkdirp`, `imageUtil` (`requireSharp`, `cropBase64Image`), `mjpeg` (`MJpegStream`),
`net`'s FTP upload support, `util`'s `uuidV1`/`uuidV3`/`uuidV5`/`localIp`/`cancellableDelay`/`multiResolve`,
`fs`'s `readPackageJsonFrom`/`findRoot`/`F_OK`/`R_OK`/`W_OK`/`X_OK`, `npm`/`NPM` (moved into the
`appium` package), and `env` (moved into the `appium` package). `node.requirePackage()` and
`system.macOsxVersion()` have also been removed. `fs.glob()` is now backed by Node's native
`fs.promises.glob` instead of the `glob` package, with a narrower `GlobOptions` shape.
!!! info "Actions Needed"
None for typical drivers/plugins, which don't use these low-level helpers directly. If you do,
switch to the suggested replacement or a native Node.js API
### Legacy Test Pages Removed From `@appium/base-driver`
The built-in test fixture routes (`/welcome`, `/test/guinea-pig*`, `/produce_error`, `/crash`),
the `STATIC_DIR` export, and the `APPIUM_ENABLE_LEGACY_TEST_PAGES` environment variable have been
removed.
!!! info "Actions Needed"
Driver test suites that relied on these fixtures should host their own local test server
## New Features
* [`--app-url-rules`](./security.md#restricting-remote-app-urls) lets server operators restrict
which hosts/IPs an `appium:app` URL is allowed to be downloaded from (allow/deny lists, HTTPS-only,
credentials, redirect limits)
* Definitions for all current [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/) commands are
now included for introspection/validation purposes
* `ExecuteMethodMap` type declarations now check `params` against the target method's signature at
compile time, catching mismatched required/optional execute method parameters in driver code
* Extension manifests and `extensions.yaml` are now validated with `ajv`/JSON Schema; a corrupted
`extensions.yaml` now resets to an empty manifest with a warning instead of crashing later with a
confusing error
* `appium driver`/`appium plugin` CLI commands now lock the extension manifest while running,
preventing concurrent processes from silently clobbering each other's changes
+10 -5
View File
@@ -46,7 +46,7 @@ Subscribes to one or more BiDi events.
#### Response
`null`
`{subscription: string}` — a unique subscription ID that can be passed to `session.unsubscribe`.
### bidiUnsubscribe
@@ -56,15 +56,20 @@ session.unsubscribe
> WebDriver BiDi documentation: [session.unsubscribe](https://w3c.github.io/webdriver-bidi/#command-session-unsubscribe)
Unsubscribes from one or more BiDi events.
Unsubscribes using previously returned subscription IDs, or the legacy event/context form.
Removing a subscription ID preserves any overlapping subscriptions.
#### Parameters
|Name|Description|Type|Default|
|--|--|--|--|
|`contexts?`|Contexts in which to unsubscribe from the specified events. By default, the global scope is applied.|string[]|`['']`|
|`events`|Names of events to unsubscribe from|string[]||
|`subscriptions?`|Non-empty list of subscription IDs returned by `session.subscribe`. Cannot be combined with `events` or `contexts`.|string[]||
|`contexts?`|Legacy form only: contexts in which to unsubscribe from the specified events. By default, the global scope is applied.|string[]|`['']`|
|`events?`|Legacy form: non-empty list of event names to unsubscribe from. Required when `subscriptions` is omitted.|string[]||
All subscription IDs must be known to the current session. An unknown ID causes an `invalid argument`
error without removing any subscriptions. Subscription IDs are cleared when the session is deleted.
#### Response
`null`
`{}`
@@ -265,6 +265,43 @@ preserved, and only the incomplete uploads will be stopped.
## Universal XML Plugin
Translation is on by default. It can be turned off and on during a session with the
[execute methods](../../guides/execute-methods.md) below, and applies only to the current session.
### execute
```
POST /session/:sessionId/execute/sync
```
Modifies the [`execute`](./webdriver.md#execute) endpoint:
* Adds the `universalXml: setEnabled` and `universalXml: isEnabled` execute methods. Any other
script is passed on unchanged
#### universalXml: setEnabled
Turns translation on or off for the current session. While it is off, `findElement`,
`findElements` and `getPageSource` are passed on unchanged.
##### Parameters
|Name|Description|Type|
|--|--|--|
|`enabled`|Whether node/attribute names should be translated|boolean|
##### Response
`null`
#### universalXml: isEnabled
Returns whether translation is on for the current session.
##### Response
`boolean` - `true` if translation is on
### findElement
```
@@ -13,6 +13,12 @@ Provides management options for a specific extension (driver or plugin). Both th
The following sub-subcommands are supported: `doctor`, `install`, `list`, `run`, `update`,
and `uninstall`.
For `doctor`, `run`, `update`, and `uninstall`, you can identify an installed extension by its
short name or its exact npm package name. For example, `appium driver uninstall uiautomator2`
and `appium driver uninstall appium-uiautomator2-driver` refer to the same installed driver.
Short names take precedence if a name also matches another extension's package name. Package names
can include a scope, such as `@appium/fake-driver`, but must not include a version or tag suffix.
## `doctor`
Runs doctor checks for an installed extension, which validate whether the extension has its prerequisites
@@ -31,7 +37,7 @@ appium {driver|plugin} doctor <extension-name>
|Argument|Description|
|--|--|
|`extension-name`|The short name of the installed extension|
|`extension-name`|The short name or exact npm package name of the installed extension|
#### Options
@@ -163,7 +169,7 @@ appium {driver|plugin} run <extension-name> [<script-name> [<script-args>]]
|Argument|Description|
|--|--|
|`extension-name`|The short name of the installed extension|
|`extension-name`|The short name or exact npm package name of the installed extension|
|`script-name`|The name of the script to run. If not provided, a list of available scripts is returned.|
|`script-args`|Any additional arguments passed to the script|
@@ -200,7 +206,7 @@ appium {driver|plugin} update <extension-name>
|Argument|Description|
|--|--|
|`extension-name`|The short name of the installed extension, or `installed` to update all installed extensions|
|`extension-name`|The short name or exact npm package name of the installed extension, or `installed` to update all installed extensions|
#### Options
@@ -235,7 +241,7 @@ appium {driver|plugin} uninstall <extension-name>
|Argument|Description|
|--|--|
|`extension-name`|The short name of the installed extension|
|`extension-name`|The short name or exact npm package name of the installed extension|
#### Options
@@ -14,6 +14,12 @@ Provides management options for a specific extension (driver or plugin). Both th
The following sub-subcommands are supported: `doctor`, `install`, `list`, `run`, `update`,
and `uninstall`.
For `doctor`, `run`, `update`, and `uninstall`, you can identify an installed extension by its
short name or its exact npm package name. For example, `appium driver uninstall uiautomator2`
and `appium driver uninstall appium-uiautomator2-driver` refer to the same installed driver.
Short names take precedence if a name also matches another extension's package name. Package names
can include a scope, such as `@appium/fake-driver`, but must not include a version or tag suffix.
## `doctor`
Runs doctor checks for an installed extension, which validate whether the extension has its prerequisites
@@ -30,9 +36,9 @@ configured correctly. Note that not all extensions include doctor checks.
appium {driver|plugin} doctor <extension-name>
```
| Argument | Description |
| ---------------- | ----------------------------------------- |
| `extension-name` | The short name of the installed extension |
| Argument | Description |
| ---------------- | ------------------------------------------------------------------- |
| `extension-name` | The short name or exact npm package name of the installed extension |
#### Options
@@ -164,7 +170,7 @@ appium {driver|plugin} run <extension-name> [<script-name> [<script-args>]]
| Argument | Description |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `extension-name` | The short name of the installed extension |
| `extension-name` | The short name or exact npm package name of the installed extension |
| `script-name` | The name of the script to run. If not provided, a list of available scripts is returned. |
| `script-args` | Any additional arguments passed to the script |
@@ -199,9 +205,9 @@ Appium will only update minor and patch versions, in order to prevent any breaki
appium {driver|plugin} update <extension-name>
```
| Argument | Description |
| ---------------- | -------------------------------------------------------------------------------------------- |
| `extension-name` | The short name of the installed extension, or `installed` to update all installed extensions |
| Argument | Description |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `extension-name` | The short name or exact npm package name of the installed extension, or `installed` to update all installed extensions |
#### Options
@@ -234,9 +240,9 @@ Removes an installed extension.
appium {driver|plugin} uninstall <extension-name>
```
| Argument | Description |
| ---------------- | ----------------------------------------- |
| `extension-name` | The short name of the installed extension |
| Argument | Description |
| ---------------- | ------------------------------------------------------------------- |
| `extension-name` | The short name or exact npm package name of the installed extension |
#### Options
+1
View File
@@ -57,6 +57,7 @@ nav:
- reference/api/plugins.md
- Guides:
- Migration:
- guides/migrating-3-to-4.md
- guides/migrating-2-to-3.md
- guides/migrating-1-to-2.md
- Server/Driver Configuration:
@@ -14,6 +14,12 @@ Provides management options for a specific extension (driver or plugin). Both th
The following sub-subcommands are supported: `doctor`, `install`, `list`, `run`, `update`,
and `uninstall`.
For `doctor`, `run`, `update`, and `uninstall`, you can identify an installed extension by its
short name or its exact npm package name. For example, `appium driver uninstall uiautomator2`
and `appium driver uninstall appium-uiautomator2-driver` refer to the same installed driver.
Short names take precedence if a name also matches another extension's package name. Package names
can include a scope, such as `@appium/fake-driver`, but must not include a version or tag suffix.
## `doctor`
Runs doctor checks for an installed extension, which validate whether the extension has its prerequisites
@@ -30,9 +36,9 @@ configured correctly. Note that not all extensions include doctor checks.
appium {driver|plugin} doctor <extension-name>
```
| Argument | Description |
| ---------------- | ----------------------------------------- |
| `extension-name` | The short name of the installed extension |
| Argument | Description |
| ---------------- | ------------------------------------------------------------------- |
| `extension-name` | The short name or exact npm package name of the installed extension |
#### Options
@@ -164,7 +170,7 @@ appium {driver|plugin} run <extension-name> [<script-name> [<script-args>]]
| Argument | Description |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `extension-name` | The short name of the installed extension |
| `extension-name` | The short name or exact npm package name of the installed extension |
| `script-name` | The name of the script to run. If not provided, a list of available scripts is returned. |
| `script-args` | Any additional arguments passed to the script |
@@ -199,9 +205,9 @@ Appium will only update minor and patch versions, in order to prevent any breaki
appium {driver|plugin} update <extension-name>
```
| Argument | Description |
| ---------------- | -------------------------------------------------------------------------------------------- |
| `extension-name` | The short name of the installed extension, or `installed` to update all installed extensions |
| Argument | Description |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `extension-name` | The short name or exact npm package name of the installed extension, or `installed` to update all installed extensions |
#### Options
@@ -234,9 +240,9 @@ Removes an installed extension.
appium {driver|plugin} uninstall <extension-name>
```
| Argument | Description |
| ---------------- | ----------------------------------------- |
| `extension-name` | The short name of the installed extension |
| Argument | Description |
| ---------------- | ------------------------------------------------------------------- |
| `extension-name` | The short name or exact npm package name of the installed extension |
#### Options
+2 -7
View File
@@ -1,14 +1,9 @@
#!/usr/bin/env node
import {realpathSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import * as appium from './build/lib/main.js';
// `process.argv[1]` retains the invoked path as-is (e.g. an npm-created bin symlink on Unix),
// while `import.meta.url` always reflects the resolved real path; realpath() both sides so this
// still matches when `appium` is launched via its installed bin symlink.
if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
// Node identifies the entry module even when invoked through an installed bin symlink.
if (import.meta.main) {
appium.main();
}
+53 -20
View File
@@ -1,3 +1,5 @@
import {isIPv6} from 'node:net';
import {
AppiumIpc,
BaseDriver,
@@ -6,6 +8,7 @@ import {
DriverCore,
type ExtensionCore,
generateDriverLogPrefix,
getProxyReq,
GET_STATUS_COMMAND,
isSessionCommand,
isW3cCaps,
@@ -14,6 +17,7 @@ import {
promoteAppiumOptions,
promoteAppiumOptionsForObject,
PROTOCOLS,
withoutProxyReq,
} from '@appium/base-driver';
import {util} from '@appium/support';
import type {
@@ -21,6 +25,7 @@ import type {
DriverCaps,
DriverOpts,
ExternalDriver,
HTTPMethod,
IAppiumIpc,
Plugin,
PluginClass,
@@ -275,35 +280,45 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
* @param rawW3cCapabilities - the new session capabilities in W3C format
*/
async createSession(rawW3cCapabilities: W3CAppiumDriverCaps): Promise<SessionHandlerCreateResult> {
const defaultCapabilities = structuredClone(this.args.defaultCapabilities);
const defaultSettings = pullSettings((defaultCapabilities ?? {}) as StringRecord);
const defaultCapabilities = promoteAppiumOptionsForObject(structuredClone(this.args.defaultCapabilities ?? {}));
const defaultSettings = pullSettings(defaultCapabilities as StringRecord);
if (!isW3cCaps(rawW3cCapabilities)) {
throw makeNonW3cCapsError();
}
const w3cCapabilities = structuredClone(rawW3cCapabilities);
const w3cSettings = {
const w3cCapabilities = promoteAppiumOptions(structuredClone(rawW3cCapabilities));
const sharedSettings = {
...defaultSettings,
...pullSettings(w3cCapabilities.alwaysMatch ?? {}),
};
if (w3cCapabilities.firstMatch === undefined) {
w3cCapabilities.firstMatch = [{}];
}
// Carry each candidate's settings through capability selection independently.
for (const firstMatchEntry of w3cCapabilities.firstMatch ?? []) {
Object.assign(w3cSettings, pullSettings(firstMatchEntry));
const settings = {...sharedSettings, ...pullSettings(firstMatchEntry)};
if (util.isPlainObject(firstMatchEntry) && !util.isEmpty(settings)) {
(firstMatchEntry as StringRecord)['appium:settings'] = settings;
}
}
const protocol = PROTOCOLS.W3C;
let innerSessionId: string;
let registeredSessionId: string | undefined;
let dCaps: DriverCapsWithBidiUrl;
try {
// Parse the caps into a format that the InnerDriver will accept
const parsedCaps = parseCapsForInnerDriver<AppiumDriverConstraints>(
promoteAppiumOptions(w3cCapabilities),
w3cCapabilities,
this.desiredCapConstraints,
defaultCapabilities ? promoteAppiumOptionsForObject(defaultCapabilities) : undefined,
defaultCapabilities,
);
if ('error' in parsedCaps && parsedCaps.error) {
throw parsedCaps.error;
}
const {desiredCaps, processedW3CCapabilities} = parsedCaps as ParsedDriverCaps<AppiumDriverConstraints>;
const w3cSettings = pullSettings(desiredCaps);
pullSettings(processedW3CCapabilities.alwaysMatch);
const {
driver: InnerDriver,
@@ -348,6 +363,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
DriverCapsWithBidiUrl,
];
this.sessions[innerSessionId] = driverInstance;
registeredSessionId = innerSessionId;
// create an IPC channel for the driver and all plugins on this session
this.sessionIpcs[innerSessionId] = new AppiumIpc({
maxObjSize: this.args.maxIpcDataSize,
@@ -364,9 +380,6 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
`${innerSessionId} added to master session list`,
);
// set the New Command Timeout for the inner driver
await driverInstance.startNewCommandTimeout();
// apply initial values to Appium settings (if provided)
if (driverInstance.isW3CProtocol() && !util.isEmpty(w3cSettings)) {
this.log.info(
@@ -382,14 +395,31 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
const {address, port, basePath} = this.args;
const scheme = `ws${this.server.isSecure() ? 's' : ''}`;
const host = bidiCommands.determineBiDiHost(address);
const bidiUrl = `${scheme}://${host}:${port}${basePath}${BIDI_BASE_PATH}/${innerSessionId}`;
const urlHost = isIPv6(host) ? `[${host}]` : host;
const bidiUrl = `${scheme}://${urlHost}:${port}${basePath}${BIDI_BASE_PATH}/${innerSessionId}`;
this.log.info(
`Upstream driver responded with webSocketUrl ${dCaps.webSocketUrl}, will rewrite to ` +
`${bidiUrl} for response to client`,
);
dCaps.webSocketUrl = bidiUrl;
}
// The session becomes idle only after all initialization has completed.
await driverInstance.startNewCommandTimeout();
} catch (error: unknown) {
if (registeredSessionId) {
try {
// A failed initialization must not leave a registered session or an idle timer,
// even if the driver's own deleteSession also fails.
try {
await this.sessions[registeredSessionId]?.clearNewCommandTimeout();
} finally {
await this.deleteSession(registeredSessionId);
}
} catch (cleanupError) {
this.log.warn(`Could not clean up failed session ${registeredSessionId}: ${cleanupError}`);
}
}
return {
protocol,
error: error instanceof Error ? error : new Error(String(error)),
@@ -426,6 +456,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
}
this.log.info(`Removing session '${innerSessionId}' from our master session list`);
this.cleanupBidiSockets(innerSessionId);
this.deleteLinkedSessionObjects(innerSessionId);
};
@@ -574,13 +605,9 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
const isUmbrellaCmd = isAppiumDriverCommand(cmd);
const isSessionCmd = isSessionCommand(cmd);
// if a plugin override proxying for this command and that is why we are here instead of just
// letting the protocol proxy the command entirely, determine that, get the request object for
// use later on, then clean up the args
const reqForProxy = args.at(-1)?.reqForProxy;
if (reqForProxy) {
args.pop();
}
// if a plugin overrode proxying for this command and that is why we are here instead of just
// letting the protocol proxy the command entirely, get the request object for use later on
const reqForProxy = getProxyReq();
// first do some error checking. If we're requesting a session command execution, then make
// sure that session actually exists on the session driver, and set the session driver itself
@@ -644,7 +671,11 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
if (!dstSession?.proxyCommand) {
throw new NoDriverProxyCommandError();
}
return await dstSession.proxyCommand(reqForProxy.originalUrl, reqForProxy.method, reqForProxy.body);
return await dstSession.proxyCommand(
reqForProxy.originalUrl,
reqForProxy.method as HTTPMethod,
reqForProxy.body,
);
}
if (isGetStatus) {
@@ -672,7 +703,9 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
cmdHandledBy,
next: defaultBehavior,
});
const res = await this.executeWrappedCommand({wrappedCmd, protocol});
// clear the ambient proxy request while the plugin/default chain runs, so a plugin that
// re-enters executeCommand for a different command doesn't inherit and misuse this one
const res = await withoutProxyReq(() => this.executeWrappedCommand({wrappedCmd, protocol}));
// if we had plugins, make sure to log out the helpful report about which plugins ended up
// handling the command and which didn't
+35 -17
View File
@@ -4,7 +4,8 @@ import {promisify} from 'node:util';
import type {ExtensionCore} from '@appium/base-driver';
import {errors} from '@appium/base-driver';
import {util} from '@appium/support';
import {fetchInterfaces, isBroadcastIp, V4_BROADCAST_IP} from '@appium/http-server';
import {net, util} from '@appium/support';
import type {
BiDiResultData,
ErrorBiDiCommandResponse,
@@ -17,7 +18,6 @@ import WebSocket from 'ws';
import type {AppiumDriver} from './appium.js';
import {BIDI_BASE_PATH, BIDI_EVENT_NAME} from './constants.js';
import {fetchInterfaces, isBroadcastIp, V4_BROADCAST_IP} from './helpers/network.js';
import {capitalize} from './utils/index.js';
type ExtensionPlugin = Plugin & ExtensionCore;
@@ -33,9 +33,12 @@ interface InitBiDiSocketResult {
logSocketErr: LogSocketError;
}
const MIN_WS_CODE_VAL = 1000;
const MAX_WS_CODE_VAL = 1015;
const WS_FALLBACK_CODE = 1011; // server encountered an error while fulfilling request
const {WebSocketCloseCode, MIN_APPLICATION_CLOSE_CODE, MAX_APPLICATION_CLOSE_CODE} = net;
const RESERVED_CLOSE_CODES: readonly number[] = [
WebSocketCloseCode.RESERVED,
WebSocketCloseCode.NO_STATUS_RECEIVED,
WebSocketCloseCode.ABNORMAL_CLOSURE,
];
const BIDI_EVENTS_MAP: WeakMap<AnyDriver, Record<string, number>> = new WeakMap();
const MAX_LOGGED_DATA_LENGTH = 300;
@@ -168,8 +171,7 @@ export function cleanupBidiSockets(this: AppiumDriver, sessionId: string): void
try {
this.log.debug(`Closing bidi socket(s) associated with session ${sessionId}`);
for (const ws of this.bidiSockets[sessionId]) {
// 1001 means server is going away
ws.close(1001);
ws.close(WebSocketCloseCode.GOING_AWAY);
}
} catch {}
delete this.bidiSockets[sessionId];
@@ -180,8 +182,8 @@ export function cleanupBidiSockets(this: AppiumDriver, sessionId: string): void
}
this.log.debug(`Also closing proxy connection to upstream bidi server`);
try {
// 1000 means normal closure, which seems correct when Appium is acting as the client
proxyClient.close(1000);
// normal closure seems correct when Appium is acting as the client
proxyClient.close(WebSocketCloseCode.NORMAL_CLOSURE);
} catch {}
delete this.bidiProxyClients[sessionId];
}
@@ -338,15 +340,14 @@ function initBidiProxyHandlers(this: AnyDriver, proxyClient: WebSocket, ws: WebS
`Upstream bidi socket closed connection (code ${code}, reason: '${reason}'). ` +
`Closing proxy connection to client`,
);
const intCode: number = typeof code === 'number' ? (code as number) : parseInt(code, 10);
if (Number.isNaN(intCode) || intCode < MIN_WS_CODE_VAL || intCode > MAX_WS_CODE_VAL) {
const closeCode = toSendableCloseCode(code);
if (closeCode !== code) {
driverLog.warn(
`Received code ${code} from upstream socket, but this is not a valid ` +
`websocket code. Rewriting to ${WS_FALLBACK_CODE} for ws compatibility`,
`websocket code. Rewriting to ${closeCode} for ws compatibility`,
);
code = WS_FALLBACK_CODE;
}
ws.close(code, reason);
ws.close(closeCode, reason);
});
proxyClient.on('error', (err) => {
@@ -413,7 +414,7 @@ function initBidiSocketHandlers(
// If we're proxying, might as well close the upstream connection and clean it up
if (proxyClient) {
driverLog.debug('Also closing BiDi proxy socket connection');
proxyClient.close(code, reason);
proxyClient.close(toSendableCloseCode(code), reason);
}
const eventLogCounts = BIDI_EVENTS_MAP.get(bidiHandlerDriver);
@@ -483,8 +484,12 @@ function initBidiEventListeners(
return;
}
const eventSubs = bidiHandlerDriver.bidiEventSubs[method];
if (Array.isArray(eventSubs) && eventSubs.includes(context)) {
const moduleName = method.split('.')[0];
const isSubscribed = [method, moduleName].some((event) => {
const contexts = bidiHandlerDriver.bidiEventSubs[event];
return Array.isArray(contexts) && (contexts.includes('') || contexts.includes(context));
});
if (isSubscribed) {
if (method in eventLogCounts) {
++eventLogCounts[method];
} else {
@@ -546,4 +551,17 @@ async function assertIsOpen(ws: WebSocket, timeoutMs: number = 5000): Promise<We
return ws;
}
/**
* 'close' events can report reserved codes like 1005 and 1006, which RFC 6455 forbids in a close
* frame and ws throws on, so anything ws would reject is replaced with an internal error
*/
function toSendableCloseCode(code: number): number {
const isStandard =
code >= WebSocketCloseCode.NORMAL_CLOSURE &&
code <= WebSocketCloseCode.BAD_GATEWAY &&
!RESERVED_CLOSE_CODES.includes(code);
const isApplication = code >= MIN_APPLICATION_CLOSE_CODE && code <= MAX_APPLICATION_CLOSE_CODE;
return isStandard || isApplication ? code : WebSocketCloseCode.INTERNAL_ERROR;
}
// #endregion
@@ -1,6 +1,4 @@
import net from 'node:net';
import type {ServerOpts} from '@appium/base-driver';
import type {ServerOpts} from '@appium/http-server';
import {util} from '@appium/support';
import type {AppiumServer} from '@appium/types';
import type {
@@ -14,13 +12,7 @@ import type {
import {getActiveDrivers, getActivePlugins} from '../extension/index.js';
import {log as logger} from '../logger.js';
import type {InitResult, ServerInitData} from './init-types.js';
import {
buildServerOpts,
createAppiumServer,
determineAppiumHomeSource,
logServerAddress,
logStartupInfo,
} from './main-helpers.js';
import {buildServerOpts, createAppiumServer, determineAppiumHomeSource, logStartupInfo} from './main-helpers.js';
const MAX_SERVER_PROCESS_LISTENERS = 100;
@@ -82,7 +74,6 @@ export class AppiumMainRunner {
appiumDriver.server = server;
this.attachSignalHandlers(appiumDriver, server);
this.logListeningUrl(server, parsedArgs, normalizedBasePath);
driverConfig.print();
pluginConfig.print([...pluginClasses.values()]);
@@ -136,14 +127,4 @@ export class AppiumMainRunner {
});
}
}
private logListeningUrl(
server: AppiumServer,
parsedArgs: ServerInitData['parsedArgs'],
normalizedBasePath: string,
): void {
const protocol = server.isSecure() ? 'https' : 'http';
const address = net.isIPv6(parsedArgs.address) ? `[${parsedArgs.address}]` : parsedArgs.address;
logServerAddress(`${protocol}://${address}:${parsedArgs.port}${normalizedBasePath}`);
}
}
+3 -30
View File
@@ -1,12 +1,8 @@
import type {NetworkInterfaceInfo} from 'node:os';
import {inspect as dump, type InspectOptions} from 'node:util';
import {
normalizeBasePath,
routeConfiguringFunction as makeRouter,
server as baseServer,
type ServerOpts,
} from '@appium/base-driver';
import {routeConfiguringFunction as makeRouter} from '@appium/base-driver';
import {normalizeBasePath, server as baseServer} from '@appium/http-server';
import type {ServerOpts} from '@appium/http-server';
import {console as supportConsole, util} from '@appium/support';
import type {AppiumServer, Driver, MethodMap, UpdateServerCallback} from '@appium/types';
import type {Args, CliCommandServer, ParsedArgs} from 'appium/types/index.js';
@@ -16,7 +12,6 @@ import type {AppiumDriver} from '../appium.js';
import {BIDI_BASE_PATH, CORS_FEATURE, LONG_STACKTRACE_LIMIT} from '../constants.js';
import type {DriverNameMap, PluginNameMap} from '../extension/index.js';
import {APPIUM_VER, getBuildInfo, getGitRev, updateBuildInfo} from '../helpers/build.js';
import {fetchInterfaces, isBroadcastIp, V4_BROADCAST_IP} from '../helpers/network.js';
import {log as logger} from '../logger.js';
import {validate as validateSchema} from '../schema/schema.js';
import {checkNodeOk, requireDir} from './node-helpers.js';
@@ -62,28 +57,6 @@ export function determineAppiumHomeSource(appiumHomeFromArgs?: string | null): s
return 'autodetected Appium home path';
}
/**
* Logs the REST listener URL; if the bind address is a broadcast address, lists concrete interface URLs.
*/
export function logServerAddress(url: string): void {
const urlObj = new URL(url);
logger.info(`Appium REST http interface listener started on ${url}`);
if (!isBroadcastIp(urlObj.hostname)) {
return;
}
const interfaces = fetchInterfaces(urlObj.hostname === V4_BROADCAST_IP ? 4 : 6);
const toLabel = (iface: NetworkInterfaceInfo) => {
const href = urlObj.href.replace(urlObj.hostname, iface.address);
return iface.internal ? `${href} (only accessible from the same host)` : href;
};
logger.info(
`You can provide the following ${interfaces.length === 1 ? 'URL' : 'URLs'} ` +
`in your client code to connect to this server:\n` +
interfaces.map((iface) => `\t${toLabel(iface)}`).join('\n'),
);
}
/**
* Validates Node version, optional long stack traces, schema, tmp dir, and `--build-info` early exit.
*
+5 -4
View File
@@ -162,7 +162,8 @@ function makeUninstallArgs(type: ExtensionType): ArgumentDefinitions {
{
type: 'str',
help:
`Name of the ${type} to uninstall, for example: ` + (type === DRIVER_TYPE ? DRIVER_EXAMPLE : PLUGIN_EXAMPLE),
`Short name or npm package name of the ${type} to uninstall, for example: ` +
(type === DRIVER_TYPE ? DRIVER_EXAMPLE : PLUGIN_EXAMPLE),
},
],
]);
@@ -179,7 +180,7 @@ function makeDoctorArgs(type: ExtensionType): ArgumentDefinitions {
{
type: 'str',
help:
`Name of the ${type} to run doctor checks for, for example: ` +
`Short name or npm package name of the ${type} to run doctor checks for, for example: ` +
(type === DRIVER_TYPE ? DRIVER_EXAMPLE : PLUGIN_EXAMPLE),
},
],
@@ -197,7 +198,7 @@ function makeUpdateArgs(type: ExtensionType): ArgumentDefinitions {
{
type: 'str',
help:
`Name of the ${type} to update, or "installed" to update all installed ${type}s. ` +
`Short name or npm package name of the ${type} to update, or "installed" to update all installed ${type}s. ` +
`To see available ${type} updates, run "appium ${type} list --installed --updates". ` +
'For example: ' +
(type === DRIVER_TYPE ? DRIVER_EXAMPLE : PLUGIN_EXAMPLE),
@@ -226,7 +227,7 @@ function makeRunArgs(type: ExtensionType): ArgumentDefinitions {
{
type: 'str',
help:
`Name of the ${type} to run a script from, for example: ` +
`Short name or npm package name of the ${type} to run a script from, for example: ` +
(type === DRIVER_TYPE ? DRIVER_EXAMPLE : PLUGIN_EXAMPLE),
},
],
+29 -8
View File
@@ -4,7 +4,7 @@ import {pathToFileURL} from 'node:url';
import {inspect} from 'node:util';
import {console, fs, system, util} from '@appium/support';
import type {ExtensionType, IDoctorCheck} from '@appium/types';
import type {ExtensionType, IDoctorCheck, PackageJson} from '@appium/types';
import type {
ExtInstallReceipt as AppiumExtInstallReceipt,
ExtManifest as AppiumExtManifest,
@@ -16,7 +16,6 @@ import type {
import {asyncfilter, asyncmap} from 'asyncbox';
import * as semver from 'semver';
import {SubProcess} from 'teen_process';
import type {PackageJson} from 'type-fest';
import {Doctor, EXIT_CODE as DOCTOR_EXIT_CODE} from '../doctor/doctor.js';
import type {ExtensionConfig as BaseExtensionConfig} from '../extension/extension-config.js';
@@ -268,6 +267,20 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
return new Error(this.log.decorate(message, 'error'));
}
/**
* Resolves an installed npm package name to its extension name, preferring an exact
* extension name match. Leaves unknown names unchanged for the command's error message.
*/
protected _resolveExtensionName(installSpec: string): string {
if (this.config.isInstalled(installSpec)) {
return installSpec;
}
return (
Object.entries(this.config.installedExtensions).find(([, {pkgName}]) => pkgName === installSpec)?.[0] ??
installSpec
);
}
/**
* Build the initial list data structure from installed and known extensions
*/
@@ -430,7 +443,8 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
*
* @return map of all installed extension names to extension data (without the extension just uninstalled)
*/
protected async _uninstall({installSpec}: UninstallOpts): Promise<Record<string, any>> {
protected async _uninstall({installSpec: rawInstallSpec}: UninstallOpts): Promise<Record<string, any>> {
const installSpec = this._resolveExtensionName(rawInstallSpec);
if (!this.config.isInstalled(installSpec)) {
throw this._createFatalError(`Can't uninstall ${this.type} '${installSpec}'; it is not installed`);
}
@@ -451,8 +465,9 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
/**
* Attempt to update one or more drivers using NPM
*/
protected async _update({installSpec, unsafe}: ExtensionUpdateOpts): Promise<ExtensionUpdateResult> {
const shouldUpdateAll = installSpec === UPDATE_ALL;
protected async _update({installSpec: rawInstallSpec, unsafe}: ExtensionUpdateOpts): Promise<ExtensionUpdateResult> {
const shouldUpdateAll = rawInstallSpec === UPDATE_ALL;
const installSpec = shouldUpdateAll ? rawInstallSpec : this._resolveExtensionName(rawInstallSpec);
// if we're specifically requesting an update for an extension, make sure it's installed
if (!shouldUpdateAll && !this.config.isInstalled(installSpec)) {
throw this._createFatalError(`The ${this.type} "${installSpec}" was not installed, so can't be updated`);
@@ -571,7 +586,8 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
* successfully loaded and executed for the given extension
* @throws {Error} If any of the mandatory Doctor checks fails.
*/
protected async _doctor({installSpec}: DoctorOptions): Promise<number> {
protected async _doctor({installSpec: rawInstallSpec}: DoctorOptions): Promise<number> {
const installSpec = this._resolveExtensionName(rawInstallSpec);
if (!this.config.isInstalled(installSpec)) {
throw this._createFatalError(`The ${this.type} "${installSpec}" is not installed`);
}
@@ -656,11 +672,12 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
* not found within `scripts` object.
*/
protected async _run({
installSpec,
installSpec: rawInstallSpec,
scriptName,
extraArgs = [],
bufferOutput = false,
}: RunOptions): Promise<RunOutput> {
const installSpec = this._resolveExtensionName(rawInstallSpec);
if (!this.config.isInstalled(installSpec)) {
throw this._createFatalError(`The ${this.type} "${installSpec}" is not installed`);
}
@@ -1132,7 +1149,11 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
await spinWith(this.isJsonOutput, `Checking if '${pkgName}' is compatible`, async () => {
const [serverVersion, extVersionRequirement] = await getRemoteExtensionVersionReq(pkgName, pkgVer);
if (serverVersion && extVersionRequirement && !semver.satisfies(serverVersion, extVersionRequirement)) {
if (
serverVersion &&
extVersionRequirement &&
!semver.satisfies(serverVersion, extVersionRequirement, {includePrerelease: true})
) {
throw this._createFatalError(
`'${installSpec}' cannot be installed because the server version it requires (${extVersionRequirement}) ` +
`does not meet the currently installed one (${serverVersion}). Please install ` +
+24 -23
View File
@@ -46,31 +46,32 @@ export async function runExtensionCommand<Cmd extends CliExtensionCommand, SubCm
// Serialize this against any other `driver`/`plugin` CLI command running (in this or another
// process) against the same `APPIUM_HOME`, so concurrent commands can't race to read, mutate,
// and write the same extension manifest out from under each other.
return withManifestLock<Record<string, unknown>>(config.appiumHome, async () => {
// Refresh from disk while holding the lock, in case another process wrote to the manifest
// between this process's startup read and now, and revalidate so derived state (installed
// extensions, pending validation summary, duplicate-automationName tracking) is current too.
await reloadManifest(config.manifest);
try {
return await withManifestLock<Record<string, unknown>>(config.appiumHome, async () => {
// Refresh from disk while holding the lock, in case another process wrote to the manifest
// between this process's startup read and now, and revalidate so derived state (installed
// extensions, pending validation summary, duplicate-automationName tracking) is current too.
await reloadManifest(config.manifest);
let jsonResult: Record<string, unknown> = {};
const CommandClass = commandClasses[type] as ExtCommand<Cmd>;
const cmd = new CommandClass({config, json} as any);
cmd.printPendingValidationSummary();
try {
jsonResult = (await cmd.execute(args)) as Record<string, unknown>;
} catch (err) {
// in the suppress output case, we are calling this function internally and should
// just throw instead of printing an error and ending the process
if (suppressOutput) {
throw err;
const CommandClass = commandClasses[type] as ExtCommand<Cmd>;
const cmd = new CommandClass({config, json} as any);
cmd.printPendingValidationSummary();
const jsonResult = (await cmd.execute(args)) as Record<string, unknown>;
if (json && !suppressOutput) {
console.log(JSON.stringify(jsonResult, null, JSON_SPACES));
}
errAndQuit(json, err);
}
if (json && !suppressOutput) {
console.log(JSON.stringify(jsonResult, null, JSON_SPACES));
return jsonResult;
});
} catch (err) {
// in the suppress output case, we are calling this function internally and should
// just throw instead of printing an error and ending the process
if (suppressOutput) {
throw err;
}
return jsonResult;
});
// `errAndQuit` exits the process, so this must run only once `withManifestLock` has already
// released the lock -- never from inside its callback, or the lock would be left behind.
errAndQuit(json, err);
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
/* eslint-disable no-console */
import {console as supportConsole} from '@appium/support';
import ora from 'ora';
import yoctoSpinner from 'yocto-spinner';
export const JSON_SPACES = 4;
@@ -82,13 +82,13 @@ export async function spinWith<T>(json: boolean, msg: string, fn: () => T | Prom
if (json) {
return await fn();
}
const spinner = ora(msg).start();
const spinner = yoctoSpinner({text: msg}).start();
try {
const res = await fn();
spinner.succeed();
spinner.success();
return res;
} catch (err) {
spinner.fail();
spinner.error();
throw err;
}
}
@@ -2,7 +2,7 @@ import {createRequire} from 'node:module';
import path from 'node:path';
import {pathToFileURL} from 'node:url';
import {fs, system, util} from '@appium/support';
import {fs, util} from '@appium/support';
import type {ExtensionType, StringRecord} from '@appium/types';
import type {ExtClass, ExtManifest, ExtName, ExtRecord} from 'appium/types/index.js';
import {satisfies} from 'semver';
@@ -125,8 +125,9 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
// syntax for; parsing directly avoids that entirely.
moduleObject = JSON.parse(await fs.readFile(schemaPath, 'utf8'));
} else {
// https://github.com/nodejs/node/issues/31710
const importPath = system.isWindows() ? pathToFileURL(schemaPath).href : schemaPath;
// Windows still requires a file URL (https://github.com/nodejs/node/issues/31710).
// POSIX paths also need URL escaping for characters such as '#' and '%'.
const importPath = pathToFileURL(schemaPath).href;
const mod = (await import(importPath)) as Record<string, any>;
moduleObject = 'default' in mod ? mod.default : mod;
}
@@ -305,8 +306,9 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
async requireAsync(extName: ExtName<ExtType>): Promise<ExtClass<ExtType>> {
const [reqPath, mainClass] = await this._resolveExtension(extName);
log.debug(`Requiring ${this.extensionType} at ${reqPath}`);
// https://github.com/nodejs/node/issues/31710
let importPath = system.isWindows() ? pathToFileURL(reqPath).href : reqPath;
// Windows still requires a file URL (https://github.com/nodejs/node/issues/31710).
// POSIX paths also need URL escaping for characters such as '#' and '%'.
let importPath = pathToFileURL(reqPath).href;
// note: this will only reload the entry point, not files it imports internally
if (process.env.APPIUM_RELOAD_EXTENSIONS) {
// For a CJS extension, `import()` delegates to Node's CJS loader, which caches by
@@ -469,7 +471,7 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
const createPeerWarning = (reason: string): string =>
`${extTypeText} "${extName}" (package \`${pkgName}\`) may be incompatible with the current version of Appium (v${APPIUM_VER}) due to ${reason}`;
if (typeof appiumVersion === 'string' && !satisfies(APPIUM_VER, appiumVersion)) {
if (typeof appiumVersion === 'string' && !satisfies(APPIUM_VER, appiumVersion, {includePrerelease: true})) {
const listData = await this.getListData();
const extListData = listData[extName] as ExtensionListData<ExtType> | undefined;
if (extListData?.installed) {
@@ -607,7 +609,7 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
}
/**
* Resolves a package `exports` field (string, `"."`, or `"import"`) to a relative entry path for ESM packages.
* Resolves a package `exports` field (string, `"."`, `"import"`, or `"default"`) to an ESM entry path.
*
* @param exportsValue - `package.json` `exports` value or nested fragment
*/
@@ -620,9 +622,16 @@ export function resolveEsmEntryPoint(exportsValue: unknown): string | undefined
}
const obj = exportsValue as Record<string, unknown>;
for (const key of ['.', 'import'] as const) {
if (obj[key]) {
return resolveEsmEntryPoint(obj[key]);
if ('.' in obj) {
return resolveEsmEntryPoint(obj['.']);
}
// Matching conditions are evaluated in package.json order, including an early default.
for (const [condition, target] of Object.entries(obj)) {
if (condition === 'import' || condition === 'default') {
const entryPoint = resolveEsmEntryPoint(target);
if (entryPoint) {
return entryPoint;
}
}
}
}
-27
View File
@@ -1,27 +0,0 @@
import os from 'node:os';
export const V4_BROADCAST_IP = '0.0.0.0';
export const V6_BROADCAST_IP = '::';
/**
* Returns network interfaces for the given IP family.
*
* @param family - 4 for IPv4, 6 for IPv6, or null for all.
*/
export function fetchInterfaces(family: 4 | 6 | null = null): os.NetworkInterfaceInfo[] {
let familyValue: (4 | 6 | string)[] | null = null;
if (family === 4) {
familyValue = [4, 'IPv4'];
} else if (family === 6) {
familyValue = [6, 'IPv6'];
}
const ifaces = Object.values(os.networkInterfaces()).filter(Boolean) as os.NetworkInterfaceInfo[][];
return ifaces.flat().filter((info) => !familyValue || familyValue.includes(info.family as 4 | 6 | string));
}
/**
* Returns true if the address is a broadcast IP (0.0.0.0 or ::).
*/
export function isBroadcastIp(address: string): boolean {
return [V4_BROADCAST_IP, V6_BROADCAST_IP, `[${V6_BROADCAST_IP}]`].includes(address);
}
+1 -5
View File
@@ -1,8 +1,5 @@
#!/usr/bin/env node
import {realpathSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import './logsink.js'; // must run first: global npmlog / log sink setup (see logsink module)
import './logger.js'; // load Appium logger immediately after logsink (order matters for log wiring)
import type {AppiumServer} from '@appium/types';
@@ -50,8 +47,7 @@ export async function main<
// NOTE: backwards compat for scripts referencing `build/lib/main.js` directly.
// The executable is `../index.js`, so that module will typically not match here.
// realpath() both sides so this still matches through a bin symlink (see index.js).
if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
if (import.meta.main) {
void main();
}
+19 -2
View File
@@ -52,7 +52,7 @@ export const findAppiumDependencyPackage = util.memoize(async function findAppiu
const version = semver.minVersion(
String(pkg.dependencies?.appium ?? pkg.devDependencies?.appium ?? pkg.peerDependencies?.appium),
);
return version && semver.satisfies(version, acceptableVersionRange) ? root : undefined;
return version && semver.satisfies(version, acceptableVersionRange, {includePrerelease: true}) ? root : undefined;
} catch {
return undefined;
}
@@ -177,5 +177,22 @@ export async function withManifestLock<T>(appiumHome: string, behavior: () => Pr
// The locks directory lives under the user's home dir, independent of `appiumHome`'s own
// permissions, but may not exist yet on first use.
await fs.mkdirp(path.dirname(lockFile));
return util.getLockFileGuard<T>(lockFile)(behavior);
let acquired = false;
try {
return await util.getLockFileGuard<T>(lockFile)(() => {
acquired = true;
return behavior();
});
} catch (err) {
if (!acquired && err instanceof Error && (err.cause as NodeJS.ErrnoException | undefined)?.code === 'EEXIST') {
// A timeout does not prove the lock is abandoned. Never remove a potentially active lock.
throw new Error(
`${err.message} Another process may still be using APPIUM_HOME '${appiumHome}'. ` +
`If a previous process exited unexpectedly, stop all Appium servers and extension CLI ` +
`commands using this APPIUM_HOME before manually removing '${lockFile}', then retry.`,
{cause: err},
);
}
throw err;
}
}
+1 -2
View File
@@ -2,11 +2,10 @@ import Module from 'node:module';
import path from 'node:path';
import {fs, system, util} from '@appium/support';
import type {StringRecord} from '@appium/types';
import type {PackageJson, StringRecord} from '@appium/types';
import * as semver from 'semver';
import {exec} from 'teen_process';
import type {ExecError, TeenProcessExecOptions} from 'teen_process';
import type {PackageJson} from 'type-fest';
import {INSTALL_LOCKFILE_RELATIVE_PATH} from '../constants.js';
+1 -1
View File
@@ -1,7 +1,7 @@
import nodeFs from 'node:fs';
import path from 'node:path';
import type {PackageJson} from 'type-fest';
import type {PackageJson} from '@appium/types';
type AppiumPackageJson = PackageJson & {
name: string;
+1 -3
View File
@@ -1,10 +1,8 @@
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import type {PackageJson} from '@appium/types';
import normalizePackageData from 'normalize-package-data';
import type {PackageJson as TypeFestPackageJson} from 'type-fest';
export type PackageJson = TypeFestPackageJson;
export type NormalizedPackageJson = PackageJson & {
name: string;
+15 -14
View File
@@ -1,6 +1,6 @@
{
"name": "appium",
"version": "4.0.0-beta.0",
"version": "4.0.0-beta.3",
"gitHead": "f7b20335eab4022e5cbbb627ec86866a994444f8",
"description": "Automation for Apps.",
"keywords": [
@@ -39,7 +39,8 @@
"support.*",
"plugin.*",
"types",
"tsconfig.json"
"tsconfig.json",
"!scripts/ci"
],
"type": "module",
"main": "./build/lib/main.js",
@@ -60,17 +61,18 @@
"publish:docs": "cross-env APPIUM_DOCS_PUBLISH=1 npm run build:docs",
"test": "npm run test:unit",
"test:e2e": "node --test --experimental-test-module-mocks --enable-source-maps --test-force-exit --test-concurrency=1 --test-timeout=200000 \"./build/test/e2e/**/*.spec.js\"",
"test:smoke": "cross-env APPIUM_HOME=./local_appium_home node ./index.js driver install uiautomator2 && cross-env APPIUM_HOME=./local_appium_home node ./index.js driver list",
"test:smoke": "cross-env APPIUM_HOME=./local_appium_home node ./scripts/ci/smoke-install-known-driver.mjs uiautomator2 && cross-env APPIUM_HOME=./local_appium_home node ./index.js driver list",
"test:unit": "node --test --experimental-test-module-mocks --test-concurrency=1 --enable-source-maps --test-timeout=5000 \"./build/test/unit/**/*.spec.js\""
},
"dependencies": {
"@appium/base-driver": "11.0.0-beta.0",
"@appium/base-plugin": "4.0.0-beta.0",
"@appium/docutils": "4.0.0-beta.0",
"@appium/logger": "3.0.0-beta.0",
"@appium/schema": "2.0.0-beta.0",
"@appium/support": "8.0.0-beta.0",
"@appium/types": "2.0.0-beta.0",
"@appium/base-driver": "11.0.0-beta.2",
"@appium/base-plugin": "4.0.0-beta.2",
"@appium/docutils": "4.0.0-beta.2",
"@appium/http-server": "1.0.0-beta.2",
"@appium/logger": "3.0.0-beta.1",
"@appium/schema": "2.0.0-beta.1",
"@appium/support": "8.0.0-beta.2",
"@appium/types": "2.0.0-beta.2",
"@sidvind/better-ajv-errors": "7.0.0",
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
@@ -79,14 +81,13 @@
"axios": "1.19.0",
"lilconfig": "3.1.3",
"lru-cache": "11.5.2",
"normalize-package-data": "8.0.0",
"ora": "5.4.1",
"normalize-package-data": "9.0.0",
"semver": "7.8.5",
"teen_process": "4.2.1",
"type-fest": "5.8.0",
"winston": "3.19.0",
"ws": "8.21.3",
"yaml": "2.9.0"
"yaml": "2.9.0",
"yocto-spinner": "1.2.2"
},
"devDependencies": {
"@types/json-schema": "7.0.15",
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// @ts-check
/* eslint-disable no-console */
/**
* Installs a known driver as part of `npm run test:smoke`, tolerating the one failure mode that
* isn't ours to fix: the driver's currently-published version not yet declaring compatibility
* with a freshly-bootstrapped prerelease Appium major (its maintainers haven't caught up yet).
* Any other failure still fails the smoke test normally.
*/
import {execFileSync} from 'node:child_process';
const info = 'ℹ';
const warning = '⚠';
const INCOMPATIBLE_MARKER = 'cannot be installed because the server version it requires';
const [, , driverName] = process.argv;
if (!driverName) {
console.error('Usage: smoke-install-known-driver.mjs <driverName>');
process.exitCode = 1;
} else {
try {
const output = execFileSync(process.execPath, ['./index.js', 'driver', 'install', driverName], {
encoding: 'utf8',
});
console.log(output);
} catch (err) {
const output = `${err.stdout ?? ''}${err.stderr ?? ''}`;
console.log(output);
if (output.includes(INCOMPATIBLE_MARKER)) {
console.log(
`${warning} Skipping smoke install of '${driverName}': its published version doesn't declare ` +
`compatibility with this Appium version yet. This will resolve itself once a compatible ` +
`release exists.`,
);
} else {
console.log(`${info} Re-raising unexpected failure installing '${driverName}'.`);
process.exitCode = typeof err.status === 'number' ? err.status : 1;
}
}
}
+112 -19
View File
@@ -5,6 +5,7 @@ import {describe, it, before, after, beforeEach, type TestContext} from 'node:te
import {fs, system, tempDir, util} from '@appium/support';
import type {DriverType} from '@appium/types';
import type {ExtRecord} from 'appium/types/index.js';
import * as semver from 'semver';
import {exec} from 'teen_process';
import {
@@ -16,12 +17,51 @@ import {
EXT_SUBCOMMAND_UNINSTALL as UNINSTALL,
KNOWN_DRIVERS,
} from '../../lib/constants.js';
import {APPIUM_VER} from '../../lib/helpers/build.js';
import {omitKeys, resolveFrom} from '../../lib/utils/index.js';
import {FAKE_DRIVER_DIR, resolveFixture} from '../helpers.js';
import {installLocalExtension, runAppiumJson, runAppiumRaw} from './e2e-helpers.js';
const TEST_DRIVER_DIR = path.dirname(resolveFixture('test-driver/package.json'));
// While this branch's Appium runs as a prerelease (e.g. a beta major train), `npm install
// @appium/fake-driver` with no tag would resolve `latest` - a release line that doesn't
// declare compatibility with the running prerelease major. Route npm-registry installs to the
// `beta` dist-tag instead so they resolve to a version that actually supports it.
const IS_PRERELEASE_SERVER = Boolean(semver.prerelease(APPIUM_VER));
const FAKE_DRIVER_NPM_SPEC = IS_PRERELEASE_SERVER ? '@appium/fake-driver@beta' : '@appium/fake-driver';
/**
* True for the specific rejection `ExtensionCliCommand#_checkInstallCompatibility` throws when an
* extension's declared peer requirement on `appium` doesn't cover the running server version -
* as opposed to some other, unrelated install failure.
*/
function isServerVersionIncompatibleError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return message.includes('cannot be installed because the server version it requires');
}
/**
* Installs `FAKE_DRIVER_NPM_SPEC` from npm, or skips the calling test if the currently-published
* version under that spec doesn't yet declare compatibility with the running (possibly
* prerelease) Appium - e.g. right after a fresh major bootstrap, before a follow-up release
* catches its peerDependencies range up. Self-resolves once that follow-up release ships.
*/
async function installFakeDriverOrSkip(
ctx: TestContext,
install: (args: string[]) => Promise<ExtRecord<DriverType>>,
): Promise<ExtRecord<DriverType> | undefined> {
try {
return await install([FAKE_DRIVER_NPM_SPEC, '--source', 'npm']);
} catch (err) {
if (isServerVersionIncompatibleError(err)) {
ctx.skip(`${FAKE_DRIVER_NPM_SPEC} is not yet compatible with Appium ${APPIUM_VER}: ${(err as Error).message}`);
return undefined;
}
throw err;
}
}
const TEST_DRIVER_INVALID_PEERS_DIR = path.dirname(resolveFixture('test-driver-invalid-peer-dep/package.json'));
interface ExtensionListResult {
@@ -108,18 +148,46 @@ describe('Driver CLI', {timeout: 90000}, function () {
if (system.isWindows()) {
return ctx.skip();
}
const versions = JSON.parse(
(
await exec('npm', ['view', '@appium/fake-driver', 'versions', '--json'], {
encoding: 'utf-8',
})
).stdout,
) as string[];
const [versions, distTags] = await Promise.all([
exec('npm', ['view', '@appium/fake-driver', 'versions', '--json'], {encoding: 'utf-8'}).then(
({stdout}) => JSON.parse(stdout) as string[],
),
exec('npm', ['view', '@appium/fake-driver', 'dist-tags', '--json'], {encoding: 'utf-8'}).then(
({stdout}) => JSON.parse(stdout) as Record<string, string>,
),
]);
const penultimateFakeDriverVersionAsOfRightNow = versions[versions.length - 2];
// Compare within the release line the running (possibly prerelease) Appium is actually
// compatible with, rather than blindly against the last-published version ever - which,
// right after a fresh major bootstrap, may be a still-incompatible `latest`.
// Update checks skip prereleases, so also exclude those to guarantee an update exists.
const referenceVersion = IS_PRERELEASE_SERVER ? distTags.beta : distTags.latest;
const sameLineVersions = versions.filter(
(v) => semver.major(v) === semver.major(referenceVersion) && !semver.prerelease(v),
);
if (sameLineVersions.length < 2) {
// Only the just-bootstrapped version exists in this release line so far; there's nothing
// older-but-compatible to install as a baseline yet. Self-resolves once a second release
// lands in this line.
return ctx.skip();
}
const penultimateFakeDriverVersionAsOfRightNow = sameLineVersions[sameLineVersions.length - 2];
await resetAppiumHome();
await runInstall([`@appium/fake-driver@${penultimateFakeDriverVersionAsOfRightNow}`, '--source', 'npm']);
try {
await runInstall([`@appium/fake-driver@${penultimateFakeDriverVersionAsOfRightNow}`, '--source', 'npm']);
} catch (err) {
// The penultimate release in the line may predate a peerDependencies catch-up that
// followed a fresh major bootstrap, and so may not itself support the running server.
// Self-resolves once enough newer, compatible releases exist in the line.
if (isServerVersionIncompatibleError(err)) {
return ctx.skip(
`@appium/fake-driver@${penultimateFakeDriverVersionAsOfRightNow} is not yet compatible with ` +
`Appium ${APPIUM_VER}: ${(err as Error).message}`,
);
}
throw err;
}
const listResult = (await runList(['--updates'])) as Record<
string,
{updateVersion?: string; unsafeUpdateVersion?: string}
@@ -158,8 +226,19 @@ describe('Driver CLI', {timeout: 90000}, function () {
await assert.rejects(fs.stat(path.join(appiumHome, 'node_modules', 'appium')));
});
it('should install a driver from the list of known drivers', async function () {
const ret = await runInstall(['uiautomator2']);
it('should install a driver from the list of known drivers', async function (ctx: TestContext) {
// uiautomator2 is a separate, externally-maintained package; while the running Appium is a
// prerelease, its published version(s) may not yet declare compatibility with it. Skip only
// for that specific reason, so this starts passing again the moment a compatible one ships.
let ret: ExtRecord<DriverType>;
try {
ret = await runInstall(['uiautomator2']);
} catch (err) {
if (isServerVersionIncompatibleError(err)) {
return ctx.skip(`uiautomator2 is not yet compatible with Appium ${APPIUM_VER}: ${(err as Error).message}`);
}
throw err;
}
assert.strictEqual(ret.uiautomator2.pkgName, 'appium-uiautomator2-driver');
assert.strictEqual(ret.uiautomator2.installType, 'npm');
assert.strictEqual(ret.uiautomator2.installSpec, 'uiautomator2');
@@ -172,11 +251,14 @@ describe('Driver CLI', {timeout: 90000}, function () {
});
});
it('should install a driver from npm', async function () {
const ret = await runInstall(['@appium/fake-driver', '--source', 'npm']);
it('should install a driver from npm', async function (ctx: TestContext) {
const ret = await installFakeDriverOrSkip(ctx, runInstall);
if (!ret) {
return;
}
assert.strictEqual(ret.fake.pkgName, '@appium/fake-driver');
assert.strictEqual(ret.fake.installType, 'npm');
assert.strictEqual(ret.fake.installSpec, '@appium/fake-driver');
assert.strictEqual(ret.fake.installSpec, FAKE_DRIVER_NPM_SPEC);
const list = await runList(['--installed']);
const rest = omitKeys(list.fake ?? {}, ['installed', 'repositoryUrl']);
assertDeepInclude(rest, {
@@ -186,8 +268,10 @@ describe('Driver CLI', {timeout: 90000}, function () {
});
});
it('should install a driver from npm and a local driver', async function () {
await runInstall(['@appium/fake-driver', '--source', 'npm']);
it('should install a driver from npm and a local driver', async function (ctx: TestContext) {
if (!(await installFakeDriverOrSkip(ctx, runInstall))) {
return;
}
await installLocalExtension(appiumHome, DRIVER_TYPE, TEST_DRIVER_DIR);
const list = await runList(['--installed']);
assert.ok(list.fake);
@@ -196,9 +280,18 @@ describe('Driver CLI', {timeout: 90000}, function () {
await resolveFrom(appiumHome, '@appium/test-driver/package.json');
});
it('should install _two_ drivers from npm', async function () {
await runInstall(['@appium/fake-driver', '--source', 'npm']);
await runInstall(['appium-uiautomator2-driver', '--source', 'npm']);
it('should install _two_ drivers from npm', async function (ctx: TestContext) {
if (!(await installFakeDriverOrSkip(ctx, runInstall))) {
return;
}
try {
await runInstall(['appium-uiautomator2-driver', '--source', 'npm']);
} catch (err) {
if (isServerVersionIncompatibleError(err)) {
return ctx.skip(`uiautomator2 is not yet compatible with Appium ${APPIUM_VER}: ${(err as Error).message}`);
}
throw err;
}
const list = await runList(['--installed']);
assert.ok(list.fake);
assert.ok(list.uiautomator2);
+260 -1
View File
@@ -1,13 +1,15 @@
import assert from 'node:assert/strict';
import type {AddressInfo} from 'node:net';
import {describe, it, beforeEach, afterEach, before, after, mock} from 'node:test';
import {BaseDriver} from '@appium/base-driver';
import {BaseDriver, runWithProxyReq} from '@appium/base-driver';
import {BasePlugin} from '@appium/base-plugin';
import {FakeDriver} from '@appium/fake-driver';
import type {Capabilities, Constraints, NSCapabilities, W3CCapabilities} from '@appium/types';
import {sleep} from 'asyncbox';
import type {SinonMock, SinonSandbox, SinonStubbedMember} from 'sinon';
import {createSandbox, stub} from 'sinon';
import WebSocket, {WebSocketServer} from 'ws';
import type * as AppiumModule from '../../lib/appium.js';
import {CORS_FEATURE, PLUGIN_TYPE, SESSION_DISCOVERY_FEATURE} from '../../lib/constants.js';
@@ -198,6 +200,160 @@ describe('AppiumDriver', function () {
await appium.deleteSession(SESSION_ID);
});
it('should start the idle timeout after applying initial settings', async function () {
const clock = sandbox.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']});
fakeDriver.setProtocolW3C();
fakeDriver.sessionId = SESSION_ID;
fakeDriver.newCommandTimeoutMs = 1000;
sandbox.stub(fakeDriver, 'onIpcInit').resolves();
mockFakeDriver
.expects('createSession')
.once()
.returns([SESSION_ID, {...BASE_CAPS}]);
sandbox.stub(fakeDriver, 'updateSettings').callsFake(async () => {
await clock.tickAsync(2000);
});
const {value, error} = await appium.createSession({
...W3C_CAPS,
alwaysMatch: {...W3C_CAPS.alwaysMatch, 'appium:settings': {initial: true}},
} as any);
assert.equal(error, undefined);
assert.equal(value?.[0], SESSION_ID);
assert.equal(appium.sessions[SESSION_ID], fakeDriver);
await clock.tickAsync(999);
assert.equal(appium.sessions[SESSION_ID], fakeDriver);
await clock.tickAsync(1);
assert.equal(appium.sessions[SESSION_ID], undefined);
mockFakeDriver.verify();
});
for (const cleanupFails of [false, true]) {
it(`should roll back failed initial settings even when cleanup fails=${cleanupFails}`, async function () {
const originalError = new Error('unsupported initial setting');
fakeDriver.setProtocolW3C();
fakeDriver.sessionId = SESSION_ID;
mockFakeDriver
.expects('createSession')
.once()
.returns([SESSION_ID, {...BASE_CAPS}]);
sandbox.stub(fakeDriver, 'updateSettings').rejects(originalError);
const deleteSession = cleanupFails
? sandbox.stub(fakeDriver, 'deleteSession').rejects(new Error('cleanup failed'))
: sandbox.spy(fakeDriver, 'deleteSession');
const result = await appium.createSession({
...W3C_CAPS,
alwaysMatch: {...W3C_CAPS.alwaysMatch, 'appium:settings': {invalid: true}},
} as any);
assert.equal(result.error, originalError);
assert.equal(deleteSession.calledOnceWithExactly(SESSION_ID), true);
assert.equal(fakeDriver.noCommandTimer, null);
assert.deepEqual(Object.keys(appium.sessions), []);
assert.deepEqual(Object.keys(appium.sessionIpcs), []);
assert.deepEqual(Object.keys(appium.sessionPlugins), []);
if (!cleanupFails) {
assert.equal(fakeDriver.sessionId, null);
}
mockFakeDriver.verify();
});
}
const initialSettingsCases = [
{
name: 'only the selected firstMatch candidate',
caps: {
alwaysMatch: {...W3C_CAPS.alwaysMatch, 'appium:settings': {shared: true, choice: 'always'}},
firstMatch: [
{'appium:settings': {choice: 'first'}},
{'appium:settings': {choice: 'last', unselected: true}},
],
},
expected: {shared: true, choice: 'first'},
},
{
name: 'the next valid firstMatch candidate',
caps: {
alwaysMatch: {platformName: 'Fake'},
firstMatch: [
{'appium:automationName': 42, 'appium:settings': {rejected: true}},
{'appium:automationName': 'Fake', 'appium:settings': {selected: true}},
],
},
expected: {selected: true},
},
{
name: 'settings inside appium:options with nested values taking precedence',
caps: {
alwaysMatch: {
...W3C_CAPS.alwaysMatch,
'appium:settings': {choice: 'outer'},
'appium:options': {settings: {choice: 'nested'}, 'settings[flag]': true},
},
},
expected: {choice: 'nested', flag: true},
},
{
name: 'settings inside firstMatch appium:options',
caps: {
alwaysMatch: W3C_CAPS.alwaysMatch,
firstMatch: [{'appium:options': {settings: {choice: 'nested'}}}],
},
expected: {choice: 'nested'},
},
{
name: 'default settings without overwriting requested settings',
defaults: {'appium:options': {settings: {defaultOnly: true, choice: 'default'}}},
caps: {alwaysMatch: {...W3C_CAPS.alwaysMatch, 'appium:settings[choice]': 'requested'}},
expected: {defaultOnly: true, choice: 'requested'},
},
];
for (const {name, caps, defaults, expected} of initialSettingsCases) {
it(`should apply ${name}`, async function () {
const originalCaps = structuredClone(caps);
appium.args.defaultCapabilities = defaults;
fakeDriver.setProtocolW3C();
fakeDriver.sessionId = SESSION_ID;
const create = sandbox.stub(fakeDriver, 'createSession').resolves([SESSION_ID, {...BASE_CAPS}] as any);
const update = sandbox.stub(fakeDriver, 'updateSettings').resolves();
const {error} = await appium.createSession(caps as any);
assert.equal(error, undefined);
assert.equal(update.calledOnceWithExactly(expected), true);
const forwarded = create.firstCall.args[0];
assert.ok(forwarded);
assert.equal(
Object.keys(forwarded.alwaysMatch ?? {}).some((key) => key.includes('settings')),
false,
);
assert.deepEqual(caps, originalCaps);
});
}
for (const address of ['::1', '2001:db8::1', '127.0.0.1', 'localhost']) {
for (const secure of [false, true]) {
it(`should return a valid BiDi URL for ${address} with TLS=${secure}`, async function () {
appium.args.address = address;
appium.args.port = 4723;
appium.args.basePath = '/wd/hub';
appium.server = {isSecure: () => secure} as any;
mockFakeDriver
.expects('createSession')
.once()
.returns([SESSION_ID, {...BASE_CAPS, webSocketUrl: true}]);
const {value, error} = await appium.createSession(W3C_CAPS);
assert.equal(error, undefined);
const url = new URL(String(value![1].webSocketUrl));
assert.equal(url.protocol, secure ? 'wss:' : 'ws:');
assert.equal(url.hostname, address.includes(':') ? `[${address}]` : address);
assert.equal(url.port, '4723');
assert.equal(url.pathname, `/wd/hub/bidi/${SESSION_ID}`);
mockFakeDriver.verify();
});
}
}
it(`should call inner driver's createSession with desired capabilities`, async function () {
mockFakeDriver
.expects('createSession')
@@ -340,6 +496,48 @@ describe('AppiumDriver', function () {
await (mockFakeDriver as any).object.deleteSession();
});
});
describe('executeCommand', function () {
let appium: InstanceType<typeof AppiumModule.AppiumDriver>;
let mockFakeDriver: SinonMock;
beforeEach(function () {
[appium, mockFakeDriver] = getDriverAndFakeDriver();
(appium as any).sessions[SESSION_ID] = fakeDriver;
});
afterEach(function () {
mockFakeDriver.restore();
});
it('should not leak the ambient proxy request into a command a plugin re-enters via executeCommand', async function () {
// a plugin handling the outer (proxy-deferred) command, which re-enters dispatch for an
// unrelated command before deferring to the rest of the chain
class ReentrantPlugin extends BasePlugin {
async getPageSource(next: () => Promise<unknown>) {
await appium.executeCommand('getWindowHandle', SESSION_ID);
return await next();
}
}
appium.sessionPlugins[SESSION_ID] = [new ReentrantPlugin('reentrant')];
// the re-entered command should run normally, not be proxied
mockFakeDriver
.expects('executeCommand')
.once()
.withExactArgs('getWindowHandle', SESSION_ID)
.resolves('a-handle');
// only the outer command, which the ambient context was actually set up for, gets proxied
mockFakeDriver
.expects('proxyCommand')
.once()
.withExactArgs('/session/x/context', 'POST', {name: 'NATIVE_APP'})
.resolves('<<proxied>>');
const fakeReq = {originalUrl: '/session/x/context', method: 'POST', body: {name: 'NATIVE_APP'}} as any;
const res = await runWithProxyReq(fakeReq, () => appium.executeCommand('getPageSource', SESSION_ID));
mockFakeDriver.verify();
assert.strictEqual(res.value, '<<proxied>>');
});
});
describe('configureDriverFeatures', function () {
let appium: InstanceType<typeof AppiumModule.AppiumDriver>;
@@ -507,6 +705,67 @@ describe('AppiumDriver', function () {
await sleep(1);
assert.ok(!Object.keys(appium.sessions).includes(sessionId));
});
it('should close bidi sockets if the inner driver unexpectedly exits', async function () {
const [sessionId] = (await appium.createSession(structuredClone(W3C_CAPS))).value!;
const closed: number[] = [];
const track = (code?: number) => {
closed.push(code ?? -1);
};
appium.bidiSockets[sessionId] = [{close: track} as WebSocket];
appium.bidiProxyClients[sessionId] = {close: track} as WebSocket;
appium.sessions[sessionId].eventEmitter.emit('onUnexpectedShutdown', new Error('Oops'));
// let event loop spin so rejection is handled
await sleep(1);
assert.deepEqual(closed, [1001, 1000]);
assert.strictEqual(appium.bidiSockets[sessionId], undefined);
assert.strictEqual(appium.bidiProxyClients[sessionId], undefined);
assert.ok(!Object.keys(appium.sessions).includes(sessionId));
});
});
describe('proxied bidi socket', function () {
let appium: InstanceType<typeof AppiumModule.AppiumDriver>;
let mockFakeDriver: SinonMock;
let upstreamServer: WebSocketServer;
let appiumServer: WebSocketServer;
let client: WebSocket;
let upstreamSocket: WebSocket;
async function listen(): Promise<WebSocketServer> {
const server = new WebSocketServer({port: 0, host: '127.0.0.1'});
await new Promise((resolve) => server.once('listening', resolve));
return server;
}
beforeEach(async function () {
[appium, mockFakeDriver] = getDriverAndFakeDriver();
const [sessionId] = (await appium.createSession(structuredClone(W3C_CAPS))).value!;
upstreamServer = await listen();
(fakeDriver as any)._bidiProxyUrl = `ws://127.0.0.1:${(upstreamServer.address() as AddressInfo).port}`;
const upstreamConnected = new Promise<WebSocket>((resolve) => upstreamServer.once('connection', resolve));
appiumServer = await listen();
appiumServer.on('connection', (ws, req) => appium.onBidiConnection(ws, req));
client = new WebSocket(`ws://127.0.0.1:${(appiumServer.address() as AddressInfo).port}/bidi/${sessionId}`);
await new Promise((resolve) => client.once('open', resolve));
upstreamSocket = await upstreamConnected;
});
afterEach(async function () {
client.terminate();
await new Promise((resolve) => appiumServer.close(resolve));
await new Promise((resolve) => upstreamServer.close(resolve));
await (mockFakeDriver as any).object.deleteSession();
mockFakeDriver.restore();
});
it('should close the upstream socket when the client drops without a close frame', async function () {
const upstreamClosed = new Promise((resolve) => upstreamSocket.once('close', resolve));
client.terminate();
assert.strictEqual(await upstreamClosed, 1011);
});
it('should close the client socket when the upstream drops without a close frame', async function () {
const clientClosed = new Promise((resolve) => client.once('close', resolve));
upstreamSocket.terminate();
assert.strictEqual(await clientClosed, 1011);
});
});
describe('createPluginInstances', function () {
class NoArgsPlugin extends BasePlugin {}
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import type {IncomingMessage} from 'node:http';
import {describe, it} from 'node:test';
import {setImmediate} from 'node:timers/promises';
import {BaseDriver} from '@appium/base-driver';
import {BasePlugin} from '@appium/base-plugin';
import type {ExternalDriver} from '@appium/types';
import WebSocket from 'ws';
import {AppiumDriver} from '../../lib/appium.js';
class EventSocket extends EventEmitter {
readonly OPEN = WebSocket.OPEN;
readonly readyState = WebSocket.OPEN;
readonly messages: string[] = [];
send(data: string, callback: () => void): void {
this.messages.push(data);
callback();
}
}
describe('BiDi event delivery', function () {
const cases: Array<{events: string[]; contexts?: string[]; context?: string; received: number}> = [
{events: ['log.entryAdded'], context: 'a', received: 1},
{events: ['log.entryAdded'], contexts: ['a'], context: 'a', received: 1},
{events: ['log.entryAdded'], contexts: ['a'], context: 'b', received: 0},
{events: ['log.entryAdded'], contexts: ['a'], received: 0},
{events: ['log'], context: 'a', received: 1},
{events: ['log'], received: 1},
{events: ['log'], contexts: ['a'], context: 'a', received: 1},
{events: ['log'], contexts: ['a'], context: 'b', received: 0},
{events: ['logger'], context: 'a', received: 0},
{events: ['log.entryAdded', 'log'], context: 'a', received: 1},
];
for (const source of ['driver', 'plugin']) {
for (const {events, contexts, context, received} of cases) {
it(`delivers ${source} events for ${JSON.stringify({events, contexts, context})}`, async function () {
const appium = new AppiumDriver({} as ConstructorParameters<typeof AppiumDriver>[0]);
const driver = new BaseDriver();
const plugin = new BasePlugin('events');
const socket = new EventSocket();
appium.sessions.test = driver as ExternalDriver;
appium.sessionPlugins.test = [plugin];
try {
appium.onBidiConnection(socket as unknown as WebSocket, {url: '/bidi/test'} as IncomingMessage);
await driver.bidiSubscribe(events, contexts);
const emitter = source === 'driver' ? driver.eventEmitter : plugin.eventEmitter;
const event = {method: 'log.entryAdded', context, params: {text: 'test'}};
emitter.emit('bidiEvent', event);
await setImmediate();
assert.equal(socket.messages.length, received);
if (received) {
assert.equal(JSON.parse(socket.messages[0]).method, event.method);
}
await driver.bidiUnsubscribe(events, contexts ?? ['']);
emitter.emit('bidiEvent', event);
await setImmediate();
assert.equal(socket.messages.length, received);
} finally {
driver.eventEmitter.removeAllListeners();
plugin.eventEmitter.removeAllListeners();
socket.removeAllListeners();
}
});
}
}
});
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import {existsSync} from 'node:fs';
import path from 'node:path';
import {after, afterEach, before, beforeEach, describe, it, mock} from 'node:test';
@@ -27,6 +28,8 @@ const FAKE_DRIVER_MANIFEST = {
};
let executeCalls: any[];
let errAndQuitCalls: any[];
let lockFileUnderTest: string;
/** Stands in for `DriverCliCommand`/`PluginCliCommand` so `execute()` never touches npm/network. */
class FakeExtensionCommand {
@@ -62,6 +65,18 @@ describe('runExtensionCommand', function () {
before(async function () {
mock.module('../../../lib/cli/driver-command.js', {defaultExport: FakeExtensionCommand});
mock.module('../../../lib/cli/plugin-command.js', {defaultExport: FakeExtensionCommand});
// Records the lock file's existence synchronously, at the exact moment `errAndQuit` is
// invoked -- the real `errAndQuit` calls `process.exit()`, which (unlike a plain `throw`)
// never unwinds through a pending `finally`, so only a same-tick check can tell whether the
// lock was actually released before this ran.
mock.module('../../../lib/cli/utils.js', {
namedExports: {
errAndQuit(json: boolean, msg: unknown) {
errAndQuitCalls.push({json, msg, lockFileExistedAtCallTime: existsSync(lockFileUnderTest)});
},
JSON_SPACES: 4,
},
});
({runExtensionCommand} = await import('../../../lib/cli/extension.js'));
});
@@ -71,6 +86,7 @@ describe('runExtensionCommand', function () {
beforeEach(async function () {
executeCalls = [];
errAndQuitCalls = [];
appiumHome = await tempDir.openDir();
Manifest.getInstance.cache = new Map();
});
@@ -106,6 +122,20 @@ describe('runExtensionCommand', function () {
assert.strictEqual(await fs.exists(lockFile), false);
});
it('releases the lock before handing a non-suppressed error to errAndQuit', async function () {
const {driverConfig} = await loadExtensions(appiumHome);
lockFileUnderTest = await resolveManifestLockfilePath(appiumHome);
await runExtensionCommand(
{subcommand: DRIVER_TYPE, driverCommand: 'list', throwError: 'boom'} as any,
driverConfig,
);
assert.strictEqual(errAndQuitCalls.length, 1);
assert.strictEqual(errAndQuitCalls[0].lockFileExistedAtCallTime, false);
assert.strictEqual(await fs.exists(lockFileUnderTest), false);
});
it('re-reads the manifest under the lock, picking up changes written after the initial load', async function () {
const {driverConfig} = await loadExtensions(appiumHome);
assert.deepStrictEqual(driverConfig.installedExtensions, {});
@@ -8,17 +8,21 @@ import {describe, it, beforeEach, afterEach, before, after, mock} from 'node:tes
import * as support from '@appium/support';
import {fs} from '@appium/support';
import type {AppiumLogger} from '@appium/types';
import type {SinonSandbox, SinonStub} from 'sinon';
import type {ExtManifest} from 'appium/types/index.js';
import type {SinonSandbox, SinonSpy, SinonStub} from 'sinon';
import sinon from 'sinon';
import DriverCliCommand from '../../../lib/cli/driver-command.js';
import {ExtensionCliCommand} from '../../../lib/cli/extension-command.js';
import type {
ExtensionConfig,
injectAppiumSymlinks as injectAppiumSymlinksStatic,
} from '../../../lib/cli/extension-command.js';
import PluginCliCommand from '../../../lib/cli/plugin-command.js';
import {DriverConfig} from '../../../lib/extension/driver-config.js';
import {Manifest} from '../../../lib/extension/manifest/manifest.js';
import {appiumPackageRoot} from '../../../lib/utils/index.js';
import {PluginConfig} from '../../../lib/extension/plugin-config.js';
import {appiumPackageRoot, npm} from '../../../lib/utils/index.js';
import {FAKE_DRIVER_DIR} from '../../helpers.js';
/**
@@ -90,6 +94,183 @@ describe('ExtensionCommand', function () {
});
});
for (const type of ['driver', 'plugin'] as const) {
describe(`installed ${type} names`, function () {
const commands = ['uninstall', 'update', 'run', 'doctor'] as const;
type Command = (typeof commands)[number];
const pkgName = `appium-test-${type}`;
const scopedPkgName = `@example/${pkgName}`;
const appiumHome = path.resolve('appium-home-for-test');
let ec: ExtensionCliCommand;
let config: DriverConfig | PluginConfig;
let installedExtensions: Record<string, ExtManifest<'driver'>>;
let uninstallStub: SinonStub;
let removeStub: SinonStub;
let checkUpdateStub: SinonStub;
let updateStub: SinonStub;
let getInstallPathSpy: SinonSpy;
let existsStub: SinonStub;
let readFileStub: SinonStub;
function extension(packageName: string): ExtManifest<'driver'> {
return {
pkgName: packageName,
version: '1.0.0',
installType: 'npm',
installSpec: packageName,
installPath: path.join(appiumHome, 'node_modules', packageName),
mainClass: 'TestExtension',
automationName: 'Test',
platformNames: ['Test'],
scripts: {sample: 'scripts/sample.js'},
};
}
function invoke(command: Command, name: string): Promise<unknown> {
return ec.execute({[`${type}Command`]: command, [type]: name, unsafe: false});
}
async function assertHandles(command: Command, input: string, expectedName: string): Promise<void> {
const expected = config.installedExtensions[expectedName];
const result = await invoke(command, input);
switch (command) {
case 'uninstall':
sinon.assert.calledOnceWithExactly(uninstallStub, appiumHome, expected.pkgName);
sinon.assert.calledOnceWithExactly(removeStub, expectedName);
assert.strictEqual(result, config.installedExtensions);
break;
case 'update':
sinon.assert.calledOnceWithExactly(checkUpdateStub, expectedName);
sinon.assert.calledOnceWithExactly(updateStub, expectedName, '1.1.0');
assert.deepStrictEqual(result, {
updates: {[expectedName]: {from: '1.0.0', to: '1.1.0'}},
errors: {},
});
break;
case 'run':
sinon.assert.calledOnceWithExactly(getInstallPathSpy, expectedName);
sinon.assert.calledOnceWithExactly(existsStub, path.join(expected.installPath, 'scripts/sample.js'));
assert.deepStrictEqual(result, {});
break;
case 'doctor':
sinon.assert.calledOnceWithExactly(getInstallPathSpy, expectedName);
sinon.assert.calledOnceWithExactly(readFileStub, path.join(expected.installPath, 'package.json'), 'utf8');
assert.strictEqual(result, 0);
break;
}
}
beforeEach(function () {
sandbox = sinon.createSandbox();
const manifest = sandbox.createStubInstance(Manifest);
sandbox.stub(manifest, 'appiumHome').get(() => appiumHome);
installedExtensions = {};
manifest.getExtensionData.returns(installedExtensions);
if (type === 'driver') {
config = DriverConfig.create(manifest);
ec = new DriverCliCommand({config, json: true});
} else {
config = PluginConfig.create(manifest);
ec = new PluginCliCommand({config, json: true});
}
// avoids the decorated-message symbol prefix that would break the exact-string assertions below
(ec as any).log = {
decorate: (message: string) => message,
log: sandbox.stub(),
ok: sandbox.stub(),
info: sandbox.stub(),
warn: sandbox.stub(),
error: sandbox.stub(),
};
Object.assign(installedExtensions, {short: extension(pkgName), scoped: extension(scopedPkgName)});
uninstallStub = sandbox.stub(npm, 'uninstallPackage').resolves();
removeStub = sandbox.stub(config, 'removeExtension').resolves();
checkUpdateStub = sandbox.stub(ec as any, 'checkForExtensionUpdate').resolves({
current: '1.0.0',
safeUpdate: '1.1.0',
unsafeUpdate: null,
});
updateStub = sandbox.stub(ec as any, 'updateExtension').resolves();
getInstallPathSpy = sandbox.spy(config, 'getInstallPath');
existsStub = sandbox.stub(fs, 'exists').resolves(true);
readFileStub = sandbox.stub(fs, 'readFile').resolves('{"appium":{}}');
});
afterEach(function () {
sandbox.restore();
});
for (const command of commands) {
describe(command, function () {
for (const [input, expectedName] of [
['short', 'short'],
[pkgName, 'short'],
[scopedPkgName, 'scoped'],
]) {
it(`should accept ${input} as the installed extension`, async function () {
await assertHandles(command, input, expectedName);
});
}
it('should prefer an extension name over another extension package name', async function () {
config.installedExtensions[pkgName] = extension('another-package');
await assertHandles(command, pkgName, pkgName);
});
for (const input of ['unknown', pkgName.slice(0, -1), `${pkgName}@1.0.0`, `${scopedPkgName}@1.0.0`]) {
it(`should reject ${input} without changing the installed-extension error`, async function () {
const message =
command === 'uninstall'
? `Can't uninstall ${type} '${input}'; it is not installed`
: command === 'update'
? `The ${type} "${input}" was not installed, so can't be updated`
: `The ${type} "${input}" is not installed`;
await assert.rejects(invoke(command, input), {message});
sinon.assert.notCalled(uninstallStub);
sinon.assert.notCalled(removeStub);
sinon.assert.notCalled(checkUpdateStub);
sinon.assert.notCalled(updateStub);
sinon.assert.notCalled(getInstallPathSpy);
});
}
});
}
it('should update all extensions for installed even when it matches an extension and a package', async function () {
config.installedExtensions.short.pkgName = 'installed';
config.installedExtensions.installed = extension('another-package');
assert.deepStrictEqual(await invoke('update', 'installed'), {
updates: {
short: {from: '1.0.0', to: '1.1.0'},
scoped: {from: '1.0.0', to: '1.1.0'},
installed: {from: '1.0.0', to: '1.1.0'},
},
errors: {},
});
assert.deepStrictEqual(checkUpdateStub.args, [['short'], ['scoped'], ['installed']]);
});
it('should preserve the development extension uninstall restriction for a package name', async function () {
config.installedExtensions.short.installType = 'dev';
assert.strictEqual(await invoke('uninstall', pkgName), config.installedExtensions);
sinon.assert.notCalled(uninstallStub);
sinon.assert.notCalled(removeStub);
});
it('should preserve the non-npm extension update restriction for a package name', async function () {
config.installedExtensions.short.installType = 'local';
const result = (await invoke('update', pkgName)) as {updates: object; errors: Record<string, Error>};
assert.deepStrictEqual(result.updates, {});
assert.deepStrictEqual(Object.keys(result.errors), ['short']);
sinon.assert.notCalled(checkUpdateStub);
sinon.assert.notCalled(updateStub);
});
});
}
describe('injectAppiumSymlinks', function () {
let fsExistsStub: SinonStub;
let fsSymlinkStub: SinonStub;
@@ -52,6 +52,19 @@ describe('ExtensionConfig', function () {
});
describe('ESM module resolution', function () {
it('resolves a default condition when other conditions do not match', function () {
assert.equal(resolveEsmEntryPoint({'.': {browser: './browser.js', default: './default.js'}}), './default.js');
});
it('respects the declaration order of import and default conditions', function () {
assert.equal(resolveEsmEntryPoint({default: './default.js', import: './import.js'}), './default.js');
assert.equal(resolveEsmEntryPoint({import: './import.js', default: './default.js'}), './import.js');
});
it('resolves nested default conditions', function () {
assert.equal(resolveEsmEntryPoint({'.': {import: {default: './entry.js'}}}), './entry.js');
});
it('resolves ESM entry point with simple export', function () {
assert.strictEqual(resolveEsmEntryPoint('./index.js'), './index.js');
});
@@ -361,6 +374,24 @@ describe('ExtensionConfig', function () {
});
describe('require()', function () {
it('loads an ESM extension exposing only a default export condition', async function () {
const root = await realFs.mkdtemp(path.join(os.tmpdir(), 'appium-default-export-'));
try {
const packageJson = {type: 'module', exports: {'.': {default: './entry.js'}}};
const packageJsonPath = path.join(root, 'package.json');
await realFs.writeFile(packageJsonPath, JSON.stringify(packageJson));
await realFs.writeFile(path.join(root, 'entry.js'), 'export class DefaultDriver {}');
config.installedExtensions.default = {pkgName: 'default-driver', mainClass: 'DefaultDriver'};
mocks.sandbox.stub(config, 'getInstallPath').returns(root);
MockAppiumSupport.fs.readFile.withArgs(packageJsonPath, 'utf8').resolves(JSON.stringify(packageJson));
MockAppiumSupport.fs.exists.withArgs(path.join(root, 'entry.js')).resolves(true);
assert.equal((await config.requireAsync('default')).name, 'DefaultDriver');
} finally {
await realFs.rm(root, {recursive: true, force: true});
}
});
beforeEach(function () {
// the `ExtensionConfig` instance doesn't know about fake driver, since it hasn't been
// loaded yet. all we need for the purposes of the `require()` function is a `mainClass`, so
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import {describe, it} from 'node:test';
import {fs, tempDir} from '@appium/support';
import {DriverConfig} from '../../../lib/extension/driver-config.js';
import {Manifest} from '../../../lib/extension/manifest/index.js';
import {resetSchema} from '../../../lib/schema/schema.js';
describe('extension import paths', function () {
for (const type of ['commonjs', 'module']) {
for (const reload of [false, true]) {
it(`loads ${type} extensions and schemas from paths with URL characters, reload=${reload}`, async function () {
const root = await tempDir.openDir();
const previousReload = process.env.APPIUM_RELOAD_EXTENSIONS;
try {
if (reload) {
process.env.APPIUM_RELOAD_EXTENSIONS = '1';
} else {
delete process.env.APPIUM_RELOAD_EXTENSIONS;
}
const home = path.join(root, 'home#100%');
const packageRoot = path.join(home, 'node_modules', 'probe-driver');
await fs.mkdir(packageRoot, {recursive: true});
await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({type, main: 'index.js'}));
const declaration = 'class Probe {}';
await fs.writeFile(
path.join(packageRoot, 'index.js'),
type === 'module' ? `export ${declaration}` : `${declaration}\nmodule.exports = {Probe};`,
);
const schema = {type: 'object', properties: {enabled: {type: 'boolean'}}};
await fs.writeFile(
path.join(packageRoot, 'schema.js'),
`${type === 'module' ? 'export default' : 'module.exports ='} ${JSON.stringify(schema)};`,
);
const manifest = Manifest.getInstance(home);
const extension = {
pkgName: 'probe-driver',
mainClass: 'Probe',
version: '1.0.0',
automationName: 'Probe',
platformNames: ['Fake'],
installType: 'npm' as const,
installSpec: 'probe-driver',
installPath: packageRoot,
schema: 'schema.js',
};
manifest.setExtension('driver', 'probe', extension);
const config = DriverConfig.create(manifest);
const first = await config.requireAsync('probe');
const second = await config.requireAsync('probe');
assert.equal(first.name, 'Probe');
assert.equal(second.name, 'Probe');
assert.equal(first === second, !reload);
assert.deepEqual(await config.readExtensionSchema('probe', extension), schema);
} finally {
if (previousReload === undefined) {
delete process.env.APPIUM_RELOAD_EXTENSIONS;
} else {
process.env.APPIUM_RELOAD_EXTENSIONS = previousReload;
}
resetSchema();
await fs.rimraf(root);
}
});
}
}
});
@@ -50,6 +50,7 @@ export type MockAppiumSupportUtil = typeof supportUtil & {
export interface MockAppiumSupportConsole {
CliConsole: SinonStub;
styleText: typeof supportConsole.styleText;
}
export interface MockAppiumSupport {
@@ -111,6 +112,7 @@ export function initMocks(sandbox = createSandbox()): InitMocksResult {
},
console: {
CliConsole: sandbox.stub().returns(sandbox.createStubInstance(supportConsole.CliConsole)),
styleText: supportConsole.styleText,
},
};
@@ -172,6 +172,20 @@ describe('utils/env', function () {
});
describe('hasAppiumDependency()', function () {
for (const version of ['^4.0.0-beta.2', '~3.0.0-rc.1', '4.0.0', '^1.22.0-beta.1']) {
it(`should detect supported dependency versions including prereleases: ${version}`, async function () {
readPackage.resolves({dependencies: {appium: version}});
const root = path.resolve('/project');
const expected = version.startsWith('^1.') ? env.DEFAULT_APPIUM_HOME : root;
assert.equal(await env.resolveAppiumHome(root), expected);
});
}
it('should respect an explicit upper bound when matching a prerelease', async function () {
readPackage.resolves({dependencies: {appium: '^4.0.0-beta.2'}});
assert.equal(await env.findAppiumDependencyPackage(path.resolve('/project'), '>=2.0.0 <4.0.0-0'), undefined);
});
describe('when `appium` is not a dependency of the local package', function () {
beforeEach(function () {
readPackage.rejects(missingPackageJsonError());
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import {mkdtemp, mkdir, readFile, rm, writeFile} from 'node:fs/promises';
import * as os from 'node:os';
import path from 'node:path';
import {afterEach, beforeEach, describe, it, mock, type TestContext} from 'node:test';
import support from '@appium/support';
import type * as Env from '../../../lib/utils/env.js';
describe('manifest lock recovery guidance', function () {
let root: string;
let appiumHome: string;
let lockFile: string;
let env: typeof Env;
let acquisitionError: Error | undefined;
let importCounter = 0;
beforeEach(async function (context) {
const t = context as TestContext;
root = await mkdtemp(path.join(os.tmpdir(), 'appium-manifest-lock-'));
appiumHome = path.join(root, 'appium-home');
acquisitionError = undefined;
t.mock.module('node:os', {namedExports: {homedir: () => root}});
t.mock.module('@appium/support', {
defaultExport: support,
namedExports: {
...support,
util: {
...support.util,
// Exercise the real lock implementation without waiting its production 120-second timeout.
getLockFileGuard: (file: string) => {
if (acquisitionError) {
return async () => {
throw acquisitionError;
};
}
return support.util.getLockFileGuard(file, {timeout: 0});
},
},
},
});
env = await import(`../../../lib/utils/env.js?manifest-lock=${importCounter++}`);
lockFile = await env.resolveManifestLockfilePath(appiumHome);
await mkdir(path.dirname(lockFile), {recursive: true});
});
afterEach(async function () {
mock.reset();
await rm(root, {recursive: true, force: true});
});
it('should explain manual recovery while preserving an existing lock', async function () {
await writeFile(lockFile, 'existing lock');
let called = false;
await assert.rejects(
env.withManifestLock(appiumHome, () => {
called = true;
}),
(err: Error) => {
assert.ok(err.message.includes(appiumHome));
assert.ok(err.message.includes(lockFile));
assert.match(err.message, /stop all Appium servers and extension CLI commands/);
assert.match(err.message, /before manually removing/);
assert.ok(err.cause instanceof Error);
assert.match(err.cause.message, /Could not acquire lock/);
assert.equal((err.cause.cause as NodeJS.ErrnoException).code, 'EEXIST');
return true;
},
);
assert.equal(called, false);
assert.equal(await readFile(lockFile, 'utf8'), 'existing lock');
});
it('should leave a live owner locked and allow reuse after it releases', async function () {
await support.util.getLockFileGuard(lockFile)(async () => {
await assert.rejects(
env.withManifestLock(appiumHome, () => assert.fail('lock was stolen')),
/before manually removing/,
);
assert.equal(await readFile(lockFile, 'utf8'), '');
});
assert.equal(await env.withManifestLock(appiumHome, () => 'acquired'), 'acquired');
});
it('should preserve non-contention acquisition errors without recovery advice', async function () {
acquisitionError = new Error('permission denied', {cause: Object.assign(new Error('EACCES'), {code: 'EACCES'})});
await assert.rejects(
env.withManifestLock(appiumHome, () => assert.fail('lock was not acquired')),
(err: Error) => err === acquisitionError,
);
});
it('should preserve callback failures even when they resemble lock contention', async function () {
const failure = new Error('callback failed', {cause: Object.assign(new Error('EEXIST'), {code: 'EEXIST'})});
await assert.rejects(
env.withManifestLock(appiumHome, async () => {
throw failure;
}),
(err: Error) => err === failure,
);
assert.equal(await env.withManifestLock(appiumHome, () => 'acquired'), 'acquired');
});
});
+2
View File
@@ -9,6 +9,7 @@
"@appium/base-driver": ["../base-driver"],
"@appium/base-plugin": ["../base-plugin"],
"@appium/fake-driver": ["../fake-driver"],
"@appium/http-server": ["../http-server"],
"@appium/logger": ["../logger"],
"@appium/types": ["../types"],
"@appium/schema": ["../schema"],
@@ -22,6 +23,7 @@
{"path": "../base-driver"},
{"path": "../base-plugin"},
{"path": "../fake-driver"},
{"path": "../http-server"},
{"path": "../logger"},
{"path": "../types"},
{"path": "../schema"},
+1 -2
View File
@@ -1,5 +1,4 @@
import type {DriverType, PluginType, ServerArgs} from '@appium/types';
import type {SetOptional} from 'type-fest';
import type {DriverType, PluginType, ServerArgs, SetOptional} from '@appium/types';
import type {InstallType} from './manifest/index.js';
export type CliCommandServer = 'server';
+1 -2
View File
@@ -1,6 +1,5 @@
import type {DriverType, ExtensionType, PluginType} from '@appium/types';
import type {DriverType, ExtensionType, PackageJson, PluginType, SetRequired} from '@appium/types';
import type {SchemaObject} from 'ajv';
import type {PackageJson, SetRequired} from 'type-fest';
/**
* One of the possible extension installation stratgies
+1 -2
View File
@@ -1,6 +1,5 @@
import type {DriverType, ExtensionType, PluginType} from '@appium/types';
import type {DriverType, ExtensionType, PackageJson, PluginType, SetRequired} from '@appium/types';
import type {SchemaObject} from 'ajv';
import type {PackageJson, SetRequired} from 'type-fest';
/**
* One of the possible extension installation stratgies
+1 -2
View File
@@ -1,6 +1,5 @@
import type {DriverType, ExtensionType, PluginType} from '@appium/types';
import type {DriverType, ExtensionType, PackageJson, PluginType, SetRequired} from '@appium/types';
import type {SchemaObject} from 'ajv';
import type {PackageJson, SetRequired} from 'type-fest';
/**
* One of the possible extension installation stratgies
+44
View File
@@ -3,6 +3,50 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [11.0.0-beta.2](https://github.com/appium/appium/compare/@appium/base-driver@11.0.0-beta.1...@appium/base-driver@11.0.0-beta.2) (2026-10-05)
### ⚠ BREAKING CHANGES
* **base-driver:** when creating a new session, capabilities.firstMatch can no longer be set to an empty array, as per the WebDriver spec
### Bug Fixes
* **base-driver:** declare the path-to-regexp runtime dependency ([#22865](https://github.com/appium/appium/issues/22865)) ([63e9aa4](https://github.com/appium/appium/commit/63e9aa4f57d9c41cc4334d2666160fb054db5c45))
* **base-driver:** decode credentials in app download URLs ([#22887](https://github.com/appium/appium/issues/22887)) ([c5f4217](https://github.com/appium/appium/commit/c5f42178066e0ee6e7bb2329da2207fe3cf3bec7))
* **base-driver:** do not arm the idle timer while a command is still running ([#22828](https://github.com/appium/appium/issues/22828)) ([f330716](https://github.com/appium/appium/commit/f330716793279f3b2109c2a8ff8f0bff0fad58ac))
* **base-driver:** keep the idle timer off while commands execute ([#22867](https://github.com/appium/appium/issues/22867)) ([9de70be](https://github.com/appium/appium/commit/9de70be6e262b4c86655379b78c12bb093a07c12))
* **base-driver:** reject empty array in capabilities.firstMatch ([#22860](https://github.com/appium/appium/issues/22860)) ([6d1f28e](https://github.com/appium/appium/commit/6d1f28ed05124da1129378ce87f16b35fed0f0fc))
* **base-driver:** reject queued commands when their session ends ([#22874](https://github.com/appium/appium/issues/22874)) ([ae8a27e](https://github.com/appium/appium/commit/ae8a27e02b262c8334f962d5256960f3af11489c))
* **base-driver:** reject unsupported BiDi user-context scopes ([#22877](https://github.com/appium/appium/issues/22877)) ([b077efd](https://github.com/appium/appium/commit/b077efd67d09cbdbe1f7c9a07a75aaa6d965beee))
* **base-driver:** remove incomplete app downloads ([#22878](https://github.com/appium/appium/issues/22878)) ([c159d19](https://github.com/appium/appium/commit/c159d1925c7fa580863962fef964fe58d987dc87))
* **base-driver:** report noReset and fullReset together as session not created ([#22861](https://github.com/appium/appium/issues/22861)) ([318329f](https://github.com/appium/appium/commit/318329fba75b651e2ec93974e96cb628bb11cc77))
* **base-driver:** return protocol errors for null rejections ([#22869](https://github.com/appium/appium/issues/22869)) ([14c42f7](https://github.com/appium/appium/commit/14c42f791c34b6ef4805344df5e2146807ea25e7))
* **base-driver:** track and remove BiDi subscriptions by ID ([#22876](https://github.com/appium/appium/issues/22876)) ([b791915](https://github.com/appium/appium/commit/b791915ffcf322e1cc84dbde606be55d34f020b3))
* **base-driver:** validate reset options before creating session state ([#22870](https://github.com/appium/appium/issues/22870)) ([1b7d1e0](https://github.com/appium/appium/commit/1b7d1e0eb779d15d804034ae64e556d81e122699))
## [11.0.0-beta.1](https://github.com/appium/appium/compare/@appium/base-driver@11.0.0-beta.0...@appium/base-driver@11.0.0-beta.1) (2026-09-27)
### ⚠ BREAKING CHANGES
* **base-driver:** @appium/base-driver no longer exports server, normalizeBasePath, configureServer, ServerOpts, ConfigureServerOpts, ConfigureHttpOpts, or StartServerOpts, and no longer depends on express/morgan/body-parser/ method-override/path-to-regexp. Use the new @appium/http-server package instead.
* **base-driver:** appium/driver.js and driver.d.ts no longer re-export server/normalizeBasePath/configureServer/ServerOpts (or their types), and no longer use a blanket export *. Import from @appium/http-server directly if you were relying on this undocumented path.
* **base-driver:** exempt getStatus from the commands queue by default (#22796)
### Features
* **base-driver:** exempt getStatus from the commands queue by default ([#22796](https://github.com/appium/appium/issues/22796)) ([22a00c7](https://github.com/appium/appium/commit/22a00c7a553d73fc4c57711bb56985a51047507c))
### Bug Fixes
* **base-driver:** carry proxy req via AsyncLocalStorage instead of args ([#22799](https://github.com/appium/appium/issues/22799)) ([2596da0](https://github.com/appium/appium/commit/2596da02d201d7e5fbfd24cf9dd2801302f8abc7))
* **base-driver:** keep the default idle timeout when newCommandTimeout is null or blank ([#22815](https://github.com/appium/appium/issues/22815)) ([f313306](https://github.com/appium/appium/commit/f3133068483a7e10f23bfde0381edb03059fb0b9))
### Code Refactoring
* **base-driver:** extract the HTTP/Express server into @appium/http-server ([#22800](https://github.com/appium/appium/issues/22800)) ([225a1c6](https://github.com/appium/appium/commit/225a1c6d9d24fa2696699d4b61436a49b847d666))
## [11.0.0-beta.0](https://github.com/appium/appium/compare/@appium/base-driver@10.8.0...@appium/base-driver@11.0.0-beta.0) (2026-09-19)
### ⚠ BREAKING CHANGES
@@ -1,6 +1,13 @@
import {util} from '@appium/support';
import type {Capabilities, Constraints, NSCapabilities, StandardCapabilities, W3CCapabilities} from '@appium/types';
import type {KeyAsString, MergeExclusive} from 'type-fest';
import type {
Capabilities,
Constraints,
KeyAsString,
MergeExclusive,
NSCapabilities,
StandardCapabilities,
W3CCapabilities,
} from '@appium/types';
import {log} from '../helpers/logger.js';
import {errors} from '../protocol/errors.js';
@@ -228,23 +235,13 @@ export function parseCaps<C extends Constraints>(
firstMatch: allFirstMatchCaps = [{}] as NSCapabilities<C>[], // If 'firstMatch' is undefined set it to a singleton list with one empty object (#3.1)
} = caps;
// Reject 'firstMatch' argument if it's not an array (#3.2)
if (!Array.isArray(allFirstMatchCaps)) {
// Reject 'firstMatch' argument if it's not an array with one or more entries (#3.2)
if (!Array.isArray(allFirstMatchCaps) || allFirstMatchCaps.length === 0) {
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',
'The capabilities.firstMatch argument was not valid for the following reason(s): "capabilities.firstMatch" must be a non-empty JSON array or undefined',
);
}
// If an empty array as provided, we'll be forgiving and make it an array of one empty object
// In the future, reject 'firstMatch' argument if its array did not have one or more entries (#3.2)
if (allFirstMatchCaps.length === 0) {
log.warn(
`The firstMatch array in the given capabilities has no entries. Adding an empty entry for now, ` +
`but it will require one or more entries as W3C spec.`,
);
allFirstMatchCaps.push({});
}
// Check for non-prefixed, non-standard capabilities and log warnings if they are found
const nonPrefixedCaps = findNonPrefixedCaps(caps);
if (!util.isEmpty(nonPrefixedCaps)) {
@@ -1,8 +1,19 @@
import {util} from '@appium/support';
import type {Constraints, DriverStatus, IBidiCommands} from '@appium/types';
import {errors} from '../../protocol/errors.js';
import type {BaseDriver} from '../driver.js';
type BidiSubscriptionDriver = {bidiEventSubs: Record<string, string[]>};
// Keep each subscription separate: removing one ID must not remove overlapping subscriptions.
const subscriptionsByDriver = new WeakMap<object, Map<string, Map<string, string[]>>>();
export function clearBidiSubscriptions(driver: BidiSubscriptionDriver): void {
subscriptionsByDriver.delete(driver);
driver.bidiEventSubs = {};
}
declare module '../driver.js' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface BaseDriver<C extends Constraints> extends IBidiCommands {}
@@ -13,37 +24,88 @@ declare module '../driver.js' {
*
* @param events - the names of the events to subscribe to
* @param contexts - the context ids to scope the subscription to; an empty string means all contexts
* @param userContexts - user context ids; requires a driver-specific implementation
*/
export async function bidiSubscribe<C extends Constraints>(
this: BaseDriver<C>,
events: string[],
contexts: string[] = [''],
): Promise<void> {
for (const event of events) {
this.bidiEventSubs[event] = contexts;
contexts?: string[],
userContexts?: string[],
): Promise<{subscription: string}> {
if (userContexts !== undefined) {
if (contexts !== undefined) {
throw new errors.InvalidArgumentError('contexts and userContexts are mutually exclusive');
}
if (
!Array.isArray(userContexts) ||
!userContexts.length ||
userContexts.some((context) => typeof context !== 'string' || !context.length)
) {
throw new errors.InvalidArgumentError('userContexts must be a non-empty array of non-empty strings');
}
// BaseDriver only tracks browsing-context IDs. A driver with user contexts must override
// this command to resolve their membership instead of silently creating a global subscription.
throw new errors.UnsupportedOperationError('This driver does not support subscriptions scoped to userContexts');
}
contexts ??= [''];
assertStringList(events, 'events');
if (!Array.isArray(contexts) || contexts.some((context) => typeof context !== 'string')) {
throw new errors.InvalidArgumentError('contexts must be an array of strings');
}
const subscription = util.uuidV4();
getSubscriptions(this).set(subscription, new Map(events.map((event) => [event, [...contexts]])));
refreshEventSubscriptions(this);
return {subscription};
}
/**
* Unsubscribe the current BiDi connection from one or more events, optionally scoped to contexts
*
* @param events - the names of the events to unsubscribe from
* @param events - the names of the events to unsubscribe from (legacy attribute-based form)
* @param contexts - the context ids to remove from the subscription; an empty string means all contexts
* @param subscriptions - previously returned subscription ids, instead of event names
*/
export async function bidiUnsubscribe<C extends Constraints>(
this: BaseDriver<C>,
events: string[],
contexts: string[] = [''],
events?: string[],
contexts?: string[],
subscriptions?: string[],
): Promise<void> {
for (const event of events) {
if (!this.bidiEventSubs[event]) {
continue;
const registered = getSubscriptions(this);
if (subscriptions === undefined) {
const contextsToRemove = contexts === undefined ? [''] : contexts;
assertStringList(events, 'events');
if (!Array.isArray(contextsToRemove) || contextsToRemove.some((context) => typeof context !== 'string')) {
throw new errors.InvalidArgumentError('contexts must be an array of strings');
}
this.bidiEventSubs[event] = this.bidiEventSubs[event].filter((c) => !contexts.includes(c));
if (this.bidiEventSubs[event].length === 0) {
delete this.bidiEventSubs[event];
for (const subscription of registered.values()) {
for (const event of events) {
const remaining = subscription.get(event)?.filter((context) => !contextsToRemove.includes(context));
if (remaining?.length) {
subscription.set(event, remaining);
} else {
subscription.delete(event);
}
}
}
refreshEventSubscriptions(this);
return;
}
assertStringList(subscriptions, 'subscriptions');
if (events !== undefined || contexts !== undefined) {
throw new errors.InvalidArgumentError('subscriptions cannot be combined with events or contexts');
}
// Validate the entire request before removing anything.
for (const id of subscriptions) {
if (!registered.has(id)) {
throw new errors.InvalidArgumentError(`Unknown subscription id: ${id}`);
}
}
for (const id of subscriptions) {
registered.delete(id);
}
refreshEventSubscriptions(this);
}
/**
@@ -60,3 +122,29 @@ export async function bidiStatus<C extends Constraints>(this: BaseDriver<C>): Pr
message: 'message' in base ? (base.message as string) : `${this.constructor.name} is ready to accept commands`,
};
}
function getSubscriptions(driver: object): Map<string, Map<string, string[]>> {
let subscriptions = subscriptionsByDriver.get(driver);
if (!subscriptions) {
subscriptions = new Map();
subscriptionsByDriver.set(driver, subscriptions);
}
return subscriptions;
}
function assertStringList(value: unknown, name: string): asserts value is string[] {
if (!Array.isArray(value) || !value.length || value.some((item) => typeof item !== 'string' || !item.length)) {
throw new errors.InvalidArgumentError(`${name} must be a non-empty array of non-empty strings`);
}
}
function refreshEventSubscriptions(driver: BidiSubscriptionDriver): void {
const events = new Map<string, string[]>();
for (const subscription of getSubscriptions(driver).values()) {
for (const [event, contexts] of subscription) {
const existing = events.get(event) ?? [];
events.set(event, util.uniq([...existing, ...contexts]));
}
}
driver.bidiEventSubs = Object.fromEntries(events);
}
+11 -1
View File
@@ -16,12 +16,13 @@ import type {
import AsyncLock from 'async-lock';
import {DEFAULT_BASE_PATH, NEW_COMMAND_TIMEOUT_MS, PROTOCOLS, W3C_TIMEOUTS_MS} from '../constants.js';
import {errors} from '../protocol/index.js';
import {errors, GET_STATUS_COMMAND} from '../protocol/index.js';
import {DeviceSettings} from './device-settings.js';
import {ExtensionCore} from './extension-core.js';
import * as helpers from './helpers/index.js';
const ON_UNEXPECTED_SHUTDOWN_EVENT = 'onUnexpectedShutdown';
const QUEUE_EXEMPT_COMMANDS: ReadonlySet<string> = new Set([GET_STATUS_COMMAND]);
const ALL_DRIVERS_MATCH = '*';
const FEATURE_NAME_SEPARATOR = ':';
@@ -111,6 +112,15 @@ export class DriverCore<const C extends Constraints, Settings extends StringReco
return true;
}
/**
* Command names which bypass the commands queue by default, so they can be answered without
* waiting behind other commands (e.g. `getStatus`, which clients may poll while a long-running
* command is in progress). Override for inherited classes to add/remove exemptions.
*/
get queueExemptCommands(): ReadonlySet<string> {
return QUEUE_EXEMPT_COMMANDS;
}
/**
* make eventHistory a property and return a cloned object so a consumer can't
* inadvertently change data outside of logEvent
+88 -35
View File
@@ -24,7 +24,7 @@ import {calcSignature} from '../helpers/session.js';
import {DELETE_SESSION_COMMAND, errors} from '../protocol/index.js';
import {mergePlainObjects} from '../utils.js';
import {processCapabilities, validateCaps} from './capabilities.js';
import {bidiStatus, bidiSubscribe, bidiUnsubscribe} from './commands/bidi.js';
import {bidiStatus, bidiSubscribe, bidiUnsubscribe, clearBidiSubscriptions} from './commands/bidi.js';
import {getLogEvents, logCustomEvent} from './commands/event.js';
import {executeMethod} from './commands/execute.js';
import {
@@ -88,18 +88,10 @@ export class BaseDriver<
serverPath?: string;
supportedLogTypes: Readonly<LogDefRecord> = {};
/**
* Contains the base constraints plus whatever the subclass wants to add.
*
* Subclasses _shouldn't_ need to use this. If you need to use this, please create
* an issue:
* @see {@link https://github.com/appium/appium/issues/new}
*/
protected get _desiredCapConstraints(): Readonly<BaseDriverCapConstraints & C> {
return Object.freeze(
mergePlainObjects({}, BASE_DESIRED_CAP_CONSTRAINTS, this.desiredCapConstraints) as BaseDriverCapConstraints & C,
);
}
// Number of commands (queued or queue-exempt) currently executing. Used so the new command
// timeout is only restarted once the driver is fully idle again, not after every exempt command.
private inFlightCommandCount = 0;
private readonly pendingCommandControllers = new Set<AbortController>();
/**
* This is the main command handler for the driver. It wraps command
@@ -129,7 +121,9 @@ export class BaseDriver<
const command = invoker[cmd];
// If we don't have this command, it must not be implemented
if (!command) {
await this.startNewCommandTimeout();
if (this.isCommandsQueueEnabled && this.inFlightCommandCount === 0) {
await this.startNewCommandTimeout();
}
throw new errors.NotYetImplementedError();
}
@@ -141,7 +135,10 @@ export class BaseDriver<
wasSessionShutdownUnexpectedly = true;
unexpectedShutdownRejecter?.(e);
};
this.inFlightCommandCount++;
try {
// The preceding queued command may have armed the timer after this request arrived.
await this.clearNewCommandTimeout();
return await Promise.race([
command.call(this, ...args),
// This promise is needed to monitor if the session has been
@@ -153,6 +150,8 @@ export class BaseDriver<
}),
]);
} finally {
this.inFlightCommandCount--;
if (unexpectedShutdownRejecter && unexpectedShutdownResolver) {
// This is needed to prevent memory leaks
this.eventEmitter.removeListener(ON_UNEXPECTED_SHUTDOWN_EVENT, onUnexpectedShutdown);
@@ -166,20 +165,31 @@ export class BaseDriver<
// the timer (which is done when a new command comes in), we will trigger
// automatic session deletion in this.onCommandTimeout. Of course we don't
// want to trigger the timer when the user is shutting down the session
// intentionally
if (!wasSessionShutdownUnexpectedly && this.isCommandsQueueEnabled && cmd !== DELETE_SESSION_COMMAND) {
// intentionally. Also, since queue-exempt commands can run concurrently with a
// still-executing command, only (re)start the timer once the driver is fully idle,
// otherwise we would prematurely time out the command that is still in flight.
if (
!wasSessionShutdownUnexpectedly &&
this.isCommandsQueueEnabled &&
cmd !== DELETE_SESSION_COMMAND &&
this.inFlightCommandCount === 0
) {
// resetting existing timeout
await this.startNewCommandTimeout();
}
}
};
// exempt commands bypass the queue, so they can run while other commands are queued/running,
// without needing to wait their turn behind them
const isQueueExempt = this.queueExemptCommands.has(cmd);
const synchronizationKey = BaseDriver.name;
const commandsQueueGuard = this.commandsQueueGuard as AsyncLock & {
queues?: Record<string, unknown[]>;
};
const commandsQueueLen: number = commandsQueueGuard.queues?.[synchronizationKey]?.length ?? 0;
if (this.isCommandsQueueEnabled && commandsQueueLen > 0) {
if (this.isCommandsQueueEnabled && !isQueueExempt && commandsQueueLen > 0) {
this.log.debug(
`Scheduling the '${cmd}' command to the ${this.constructor.name} commands queue. ` +
`${util.pluralize('queue item', commandsQueueLen, true)} ${commandsQueueLen === 1 ? 'is' : 'are'} ` +
@@ -187,9 +197,10 @@ export class BaseDriver<
);
}
const res = this.isCommandsQueueEnabled
? await this.commandsQueueGuard.acquire(synchronizationKey, runCommandPromise)
: await runCommandPromise();
const res =
this.isCommandsQueueEnabled && !isQueueExempt
? await this.runQueuedCommand(synchronizationKey, runCommandPromise)
: await runCommandPromise();
// log timing information about this command
const endTime = Date.now();
@@ -257,6 +268,11 @@ export class BaseDriver<
// make sure there are no rogue timeouts
await this.clearNewCommandTimeout();
// arming this while a command is still running shuts that command down
if (this.inFlightCommandCount > 0) {
return;
}
// if command timeout is 0, it is disabled
if (!this.newCommandTimeoutMs) {
return;
@@ -328,22 +344,23 @@ export class BaseDriver<
this.validateDesiredCaps(caps);
this.sessionId = util.uuidV4();
this.sessionCreationTimestampMs = Date.now();
this.caps = caps;
// merge caps onto opts so we don't need to worry about what's where
this.opts = {...this.initialOpts, ...this.caps};
const opts = {...this.initialOpts, ...caps};
// deal with resets
// some people like to do weird things by setting noReset and fullReset
// both to true, but this is misguided and strange, so error here instead
if (this.opts.noReset && this.opts.fullReset) {
throw new Error(
if (opts.noReset && opts.fullReset) {
throw new errors.SessionNotCreatedError(
"The 'noReset' and 'fullReset' capabilities are mutually " +
'exclusive and should not both be set to true. You ' +
"probably meant to just use 'fullReset' on its own",
);
}
this.sessionId = util.uuidV4();
this.sessionCreationTimestampMs = Date.now();
this.caps = caps;
this.opts = opts;
if (this.opts.noReset === true) {
this.opts.fullReset = false;
}
@@ -358,8 +375,10 @@ export class BaseDriver<
delete this.opts.app;
}
if (this.caps.newCommandTimeout !== undefined) {
this.newCommandTimeoutMs = (this.caps.newCommandTimeout as number) * 1000;
// null and blank strings are not a timeout. `'' * 1000` is 0, which disables the idle timeout
const newCommandTimeout = this.caps.newCommandTimeout;
if (typeof newCommandTimeout === 'number' && Number.isFinite(newCommandTimeout)) {
this.newCommandTimeoutMs = newCommandTimeout * 1000;
}
this.log.prefix = helpers.generateDriverLogPrefix(this);
@@ -390,15 +409,12 @@ export class BaseDriver<
async deleteSession(sessionId?: string | null): Promise<void> {
void sessionId;
await this.clearNewCommandTimeout();
if (this.isCommandsQueueEnabled && this.commandsQueueGuard.isBusy()) {
// simple hack to release pending commands if they exist
// @ts-expect-error private API
const queues = this.commandsQueueGuard.queues;
for (const key of Object.keys(queues)) {
queues[key] = [];
}
for (const controller of this.pendingCommandControllers) {
controller.abort(new errors.NoSuchDriverError('The session was deleted before this command could run'));
}
this.pendingCommandControllers.clear();
this.sessionId = null;
clearBidiSubscriptions(this);
}
/**
@@ -472,6 +488,43 @@ export class BaseDriver<
}
return this.settings.getSettings();
}
/**
* Contains the base constraints plus whatever the subclass wants to add.
*
* Subclasses _shouldn't_ need to use this. If you need to use this, please create
* an issue:
* @see {@link https://github.com/appium/appium/issues/new}
*/
protected get _desiredCapConstraints(): Readonly<BaseDriverCapConstraints & C> {
return Object.freeze(
mergePlainObjects({}, BASE_DESIRED_CAP_CONSTRAINTS, this.desiredCapConstraints) as BaseDriverCapConstraints & C,
);
}
/**
* Reject waiting commands when their session ends without interrupting a running command.
*/
private async runQueuedCommand<T>(key: string, command: () => Promise<T>): Promise<T> {
const controller = new AbortController();
const {signal} = controller;
const cancellation = Promise.withResolvers<never>();
const onAbort = () => cancellation.reject(signal.reason);
signal.addEventListener('abort', onAbort, {once: true});
this.pendingCommandControllers.add(controller);
try {
const execution = this.commandsQueueGuard.acquire(key, async () => {
this.pendingCommandControllers.delete(controller);
signal.removeEventListener('abort', onAbort);
signal.throwIfAborted();
return await command();
});
return await Promise.race([execution, cancellation.promise]);
} finally {
this.pendingCommandControllers.delete(controller);
signal.removeEventListener('abort', onAbort);
}
}
}
Object.assign(BaseDriver.prototype, {
@@ -1,5 +1,7 @@
import type {WriteStream} from 'node:fs';
import path from 'node:path';
import type {Readable} from 'node:stream';
import {pipeline} from 'node:stream/promises';
import {fs, tempDir, timing, util} from '@appium/support';
import type {CachedAppInfo, ConfigureAppOptions, HTTPHeaders, PostProcessOptions} from '@appium/types';
@@ -35,6 +37,13 @@ const APPLICATIONS_CACHE_GUARD = new AsyncLock();
const SANITIZE_REPLACEMENT = '-';
const DEFAULT_BASENAME = 'appium-app';
const APP_DOWNLOAD_TIMEOUT_MS = 120 * 1000;
// RFC 5987: filename*=UTF-8''percent-encoded-name (language tag is optional)
const FILENAME_STAR_PATTERN = /(?:^|;)\s*filename\*\s*=\s*([^';\s]+)'[^']*'([^;]+)/i;
const QUOTED_FILENAME_PATTERN = /(?:^|;)\s*filename\s*=\s*"((?:\\.|[^"\\])*)"/i;
// unquoted token; stops at ';' so later parameters are not swallowed
const UNQUOTED_FILENAME_PATTERN = /(?:^|;)\s*filename\s*=\s*([^;\s]+)/i;
const SURROUNDING_QUOTES_PATTERN = /^["']|["']$/g;
const ESCAPED_CHAR_PATTERN = /\\(.)/g;
process.on('exit', () => {
if (APPLICATIONS_CACHE.size === 0) {
@@ -321,6 +330,16 @@ function toCacheKey(app: string): string {
return parsed.search ? parsed.href.replace(parsed.search, '') : parsed.href;
}
async function closeWriter(writer: WriteStream): Promise<void> {
if (writer.closed) {
return;
}
await new Promise<void>((resolve) => {
writer.once('close', () => resolve());
writer.destroy();
});
}
async function queryAppLink(appLink: URL, reqHeaders: RawAxiosRequestHeaders): Promise<RemoteAppData> {
const url = new URL(appLink);
// Extract credentials, then remove them from the URL for axios
@@ -328,7 +347,9 @@ async function queryAppLink(appLink: URL, reqHeaders: RawAxiosRequestHeaders): P
url.username = '';
url.password = '';
const axiosUrl = url.href;
const axiosAuth = username ? {username, password} : undefined;
const axiosAuth = username
? {username: decodeURIComponent(username), password: decodeURIComponent(password)}
: undefined;
const requestOpts: AxiosRequestConfig = {
url: axiosUrl,
auth: axiosAuth,
@@ -350,20 +371,24 @@ async function queryAppLink(appLink: URL, reqHeaders: RawAxiosRequestHeaders): P
async function fetchApp(srcStream: Readable, dstPath: string): Promise<string> {
const timer = new timing.Timer().start();
const writer = fs.createWriteStream(dstPath);
let destinationOpened = false;
writer.once('open', () => {
destinationOpened = true;
});
try {
const writer = fs.createWriteStream(dstPath);
srcStream.pipe(writer);
await new Promise<void>((resolve, reject) => {
srcStream.once('error', reject);
writer.once('finish', () => resolve());
writer.once('error', (e: Error) => {
srcStream.unpipe(writer);
reject(e);
});
});
await pipeline(srcStream, writer);
} catch (err) {
throw new Error(`Cannot fetch the application: ${(err as Error).message}`, {cause: err});
await closeWriter(writer);
if (destinationOpened) {
// This file is not in the app cache yet, so nothing else will remove it.
await fs.unlink(dstPath).catch((cleanupError: unknown) => {
const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
logger.warn(`Cannot remove incomplete application download at '${dstPath}': ${cleanupMessage}`);
});
}
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Cannot fetch the application: ${message}`, {cause: err});
}
const secondsElapsed = timer.getDuration().asSeconds;
@@ -381,6 +406,37 @@ async function fetchApp(srcStream: Readable, dstPath: string): Promise<string> {
return dstPath;
}
/**
* Picks a download filename from a Content-Disposition header.
* Prefers RFC 5987 `filename*` over `filename`, and accepts both quoted and unquoted `filename`.
* @internal
*/
export function filenameFromContentDisposition(header: string): string | undefined {
const encoded = FILENAME_STAR_PATTERN.exec(header);
if (encoded?.[2]) {
try {
const value = decodeURIComponent(encoded[2].trim().replace(SURROUNDING_QUOTES_PATTERN, ''));
if (value) {
return value;
}
} catch {
// invalid percent-encoding; fall through to filename=
}
}
// an empty quoted value is still the filename parameter, so it must not fall through to the
// token branch below, which would otherwise capture the quote characters themselves
const quoted = QUOTED_FILENAME_PATTERN.exec(header);
if (quoted) {
return quoted[1].replace(ESCAPED_CHAR_PATTERN, '$1') || undefined;
}
const unquoted = UNQUOTED_FILENAME_PATTERN.exec(header);
if (unquoted?.[1]) {
return unquoted[1];
}
}
function determineFilename(
headers: AxiosResponseHeaders | RawAxiosRequestHeaders,
pathname: string,
@@ -392,9 +448,9 @@ function determineFilename(
const extname = path.extname(basename);
if (headers['content-disposition'] && /^attachment/i.test(String(headers['content-disposition']))) {
logger.debug(`Content-Disposition: ${headers['content-disposition']}`);
const match = /filename="([^"]+)/i.exec(String(headers['content-disposition']));
if (match) {
return fs.sanitizeName(match[1], {replacement: SANITIZE_REPLACEMENT});
const fromHeader = filenameFromContentDisposition(String(headers['content-disposition']));
if (fromHeader) {
return fs.sanitizeName(fromHeader, {replacement: SANITIZE_REPLACEMENT});
}
}
@@ -1,4 +1,4 @@
export {configureApp} from './app-cache.js';
export {configureApp, filenameFromContentDisposition} from './app-cache.js';
export {setAppUrlRules} from './app-url-rules.js';
export {duplicateKeys, isPackageOrBundle, parseCapsArray} from './caps.js';
export {generateDriverLogPrefix} from '../../helpers/log-prefix.js';
@@ -2,7 +2,7 @@ import nodeFs from 'node:fs';
import path from 'node:path';
import {node} from '@appium/support';
import type {PackageJson} from 'type-fest';
import type {PackageJson} from '@appium/types';
// for compat with running tests transpiled and in-place
export const BASEDRIVER_VER = readBaseDriverVersion();
+5
View File
@@ -17,6 +17,11 @@ export const PROTOCOLS = {
// Before Appium 2.0, this default value was '/wd/hub' by historical reasons.
export const DEFAULT_BASE_PATH = '';
// Default path prefix under which drivers/plugins mount their own WebSocket handlers
// (e.g. via `AppiumServer.addWebSocketHandler`). Kept independent of the HTTP server
// implementation so drivers/plugins referencing it don't need to depend on that package.
export const DEFAULT_WS_PATHNAME_PREFIX = '/ws';
// Default values for W3C WebDriver timeouts configuration
// https://w3c.github.io/webdriver/#timeouts
export const W3C_TIMEOUTS_MS = {
@@ -0,0 +1,10 @@
/**
* Small local stand-in for the `http-status-codes` package.
* Only the codes actually used by this codebase are listed.
*/
export const HTTPStatusCodes = Object.freeze({
BAD_REQUEST: 400,
NOT_FOUND: 404,
METHOD_NOT_ALLOWED: 405,
INTERNAL_SERVER_ERROR: 500,
});
@@ -1,5 +1,4 @@
import type {StringRecord} from '@appium/types';
import {distance} from 'fastest-levenshtein';
/**
* Inclusive maximum Levenshtein edit distance for offering a "did you mean" hint.
@@ -7,6 +6,24 @@ import {distance} from 'fastest-levenshtein';
*/
export const LEVENSHTEIN_SUGGESTION_MAX_EDIT_DISTANCE = 2;
/**
* Levenshtein edit distance between two strings (classic O(n*m) DP, single-row optimized).
* Inputs here are short command/capability names, so no need for a bit-vector algorithm.
* The DP base cases already cover empty/equal strings, so no special-casing is needed.
*/
function distance(a: string, b: string): number {
let prevRow = Array.from({length: b.length + 1}, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const currRow = [i];
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
currRow.push(Math.min(prevRow[j] + 1, currRow[j - 1] + 1, prevRow[j - 1] + cost));
}
prevRow = currRow;
}
return prevRow[b.length];
}
export interface LevenshteinRankResult {
/** Candidates sorted by ascending edit distance from `target`, then alphabetically within ties. */
sorted: string[];
+2 -8
View File
@@ -8,6 +8,7 @@ export {AppiumIpc} from './basedriver/ipc.js';
export {BaseDriver};
export {
DEFAULT_BASE_PATH,
DEFAULT_WS_PATHNAME_PREFIX,
MAX_LOG_BODY_LENGTH,
NEW_COMMAND_TIMEOUT_MS,
PROTOCOLS,
@@ -17,9 +18,6 @@ export {
export * from './protocol/index.js';
// Express exports
export {normalizeBasePath, server} from './express/server.js';
// wd-proxy exports
export {WebDriverProxy} from './wd-proxy/proxy.js';
@@ -34,13 +32,9 @@ export {
validateCaps,
} from './basedriver/capabilities.js';
// Web socket helpers
export {DEFAULT_WS_PATHNAME_PREFIX} from './express/websocket.js';
// BiDi exports
export {BIDI_COMMANDS} from './protocol/bidi-commands/index.js';
export {isW3cCaps} from './helpers/capabilities.js';
export {generateDriverLogPrefix} from './helpers/log-prefix.js';
export type {ServerOpts} from './express/server.js';
export {calcSignature} from './helpers/session.js';
@@ -30,6 +30,14 @@ export const SESSION_BIDI_COMMANDS = {
// non-standard: not part of the spec, but kept for backward compatibility, since
// bidiUnsubscribe uses it to scope which contexts' subscriptions get removed
optional: ['contexts'],
validate: ({events, subscriptions, contexts}) => {
if (subscriptions !== undefined && (events !== undefined || contexts !== undefined)) {
return 'subscriptions cannot be combined with events or contexts';
}
return undefined;
},
// Preserve the selected alternative; otherwise IDs and event names become the same argument.
makeArgs: ({events, contexts, subscriptions}) => [events, contexts, subscriptions],
},
},
} as const satisfies BidiMethodMap;
+2 -1
View File
@@ -1,6 +1,7 @@
import {logger, util} from '@appium/support';
import type {Class, ErrorBiDiCommandResponse} from '@appium/types';
import {StatusCodes as HTTPStatusCodes} from 'http-status-codes';
import {HTTPStatusCodes} from '../helpers/http-status-codes.js';
const w3cLog = logger.getLogger('W3C');
@@ -2,11 +2,11 @@ import {EventEmitter} from 'node:events';
import type {OutgoingHttpHeaders} from 'node:http';
import type {Socket} from 'node:net';
import {util} from '@appium/support';
import {logger, util} from '@appium/support';
import type {NextFunction, Request, Response} from 'express';
import {LRUCache} from 'lru-cache';
import {log} from './logger.js';
const log = logger.getLogger('HTTP');
interface SessionResponse {
statusCode: number;
@@ -1,8 +1,11 @@
export {BIDI_COMMANDS} from './bidi-commands/index.js';
export {errorFromW3CJsonCode, errors, getResponseForW3CError, isErrorType} from './errors.js';
export {handleIdempotency} from './idempotency.js';
export {checkParams, makeArgs, validateExecuteMethodParams} from './params.js';
export type {CheckParamsOptions} from './params.js';
export {getProxyReq, runWithProxyReq, withoutProxyReq} from './proxy-context.js';
export {routeConfiguringFunction} from './route-handler.js';
export type {RouteConfiguringFunction, RouteConfiguringFunctionOpts} from './route-handler.js';
export {
ALL_COMMANDS,
CREATE_SESSION_COMMAND,
+1 -2
View File
@@ -1,6 +1,5 @@
import {util} from '@appium/support';
import type {PayloadParams} from '@appium/types';
import type {MultidimensionalReadonlyArray} from 'type-fest';
import type {MultidimensionalReadonlyArray, PayloadParams} from '@appium/types';
import {log} from '../helpers/logger.js';
import {omitKeys} from '../utils.js';
@@ -0,0 +1,30 @@
import {AsyncLocalStorage} from 'node:async_hooks';
import type {Request} from 'express';
// Ambient channel for the raw request behind a command that would have been proxied, but was
// deferred to plugins first (see route-handler.ts). Keeps it out of the command's args list.
const proxyReqStorage = new AsyncLocalStorage<Request | undefined>();
/**
* Runs `fn` with `req` available to `getProxyReq()` for the duration of the call.
*/
export function runWithProxyReq<T>(req: Request, fn: () => Promise<T>): Promise<T> {
return proxyReqStorage.run(req, fn);
}
/**
* Runs `fn` with no proxy request visible to `getProxyReq()`, even if one is already active.
* Use this around code that might re-enter command dispatch (e.g. a plugin/default chain), so
* that a nested command doesn't inherit and misuse the outer command's proxy request.
*/
export function withoutProxyReq<T>(fn: () => Promise<T>): Promise<T> {
return proxyReqStorage.run(undefined, fn);
}
/**
* Returns the request set up by the innermost `runWithProxyReq`, if one is currently active.
*/
export function getProxyReq(): Request | undefined {
return proxyReqStorage.getStore();
}
@@ -1,14 +1,14 @@
import {logger, util} from '@appium/support';
import type {Constraints, Core, Driver, DriverMethodDef, MethodMap} from '@appium/types';
import type {Application, Request, Response} from 'express';
import type {Constraints, Core, Driver, DriverMethodDef, ExternalDriver, MethodMap} from '@appium/types';
import type {Application, Express, Request, Response} from 'express';
import type {BaseDriver} from '../basedriver/driver.js';
import {DEFAULT_BASE_PATH, MAX_LOG_BODY_LENGTH, PROTOCOLS} from '../constants.js';
import {preserveIdempotentSessionResponse} from '../express/idempotency.js';
import type {RouteConfiguringFunction} from '../express/server.js';
import {errorFromW3CJsonCode, errors, getResponseForW3CError, isErrorType} from './errors.js';
import {ensureW3cResponse, formatResponseValue} from './helpers.js';
import {preserveIdempotentSessionResponse} from './idempotency.js';
import {checkParams, makeArgs, unwrapParams, wrapParams} from './params.js';
import {runWithProxyReq} from './proxy-context.js';
import {tryWdProxy} from './proxy.js';
import {CREATE_SESSION_COMMAND, DELETE_SESSION_COMMAND, METHOD_MAP} from './routes/index.js';
import {extractProtocol, getLogger, getSessionId, isSessionCommand} from './session.js';
@@ -16,6 +16,19 @@ import {getCommandValidator} from './validators.js';
export const deprecatedCommandsLogged: Set<string> = new Set();
/** Options for {@linkcode RouteConfiguringFunction} */
export interface RouteConfiguringFunctionOpts {
basePath?: string;
extraMethodMap?: MethodMap<ExternalDriver>;
}
/**
* A function which configures routes on an Express app for a driver.
* The Express app is only referenced by type here; it's actually constructed and run by
* whichever component operates the HTTP server (the `appium` package).
*/
export type RouteConfiguringFunction = (app: Express, opts?: RouteConfiguringFunctionOpts) => void;
/**
* Returns a function that registers default (and plugin) HTTP routes on an Express app for a driver.
* @param driver - Driver instance used to execute commands
@@ -90,14 +103,9 @@ function buildHandler(
logger.markSensitive(util.truncateString(JSON.stringify(args), {length: MAX_LOG_BODY_LENGTH})),
);
if (didPluginOverrideProxy) {
// TODO for now we add this information on the args list, but that's mixing purposes here.
// We really should add another 'options' parameter to 'executeCommand', but this would be
// a breaking change for all drivers so would need to be handled carefully.
args.push({reqForProxy: req});
}
const result = await runDriverCommand(driver, command, args, sessionId, currentProtocol);
const result = didPluginOverrideProxy
? await runWithProxyReq(req, () => runDriverCommand(driver, command, args, sessionId, currentProtocol))
: await runDriverCommand(driver, command, args, sessionId, currentProtocol);
currentProtocol = result.currentProtocol;
newSessionId = result.newSessionId;
httpResBody.value = result.driverRes;
@@ -323,15 +331,15 @@ function normalizeError(err: unknown, driver: Core<any>, sessionId: string | und
function buildErrorResponse(err: unknown, driver: Core<any>, sessionId: string | undefined): [number, any] {
let actualErr = normalizeError(err, driver, sessionId);
const stacktrace = (err as {stacktrace?: string}).stacktrace;
const stacktrace = (actualErr as Error & {stacktrace?: string}).stacktrace;
let errMsg = stacktrace || actualErr.stack || '';
if (!errMsg.includes(actualErr.message)) {
// if the message has more information, add it. but often the message
// is the first part of the stack trace
errMsg = `${actualErr.message}${errMsg ? '\n' + errMsg : ''}`;
}
if (isErrorType(err, errors.ProxyRequestError)) {
actualErr = err.getActualError();
if (isErrorType(actualErr, errors.ProxyRequestError)) {
actualErr = actualErr.getActualError();
} else {
getLogger(driver, sessionId).debug(`Encountered internal error running command: ${errMsg}`);
}
+12 -17
View File
@@ -1,6 +1,6 @@
{
"name": "@appium/base-driver",
"version": "11.0.0-beta.0",
"version": "11.0.0-beta.2",
"gitHead": "f7b20335eab4022e5cbbb627ec86866a994444f8",
"description": "Base driver class for Appium drivers",
"keywords": [
@@ -53,22 +53,17 @@
"test:types": "tsd"
},
"dependencies": {
"@appium/support": "8.0.0-beta.0",
"@appium/types": "2.0.0-beta.0",
"@types/express": "5.0.6",
"async-lock": "1.4.1",
"asyncbox": "6.4.2",
"axios": "1.19.0",
"body-parser": "2.3.0",
"express": "5.2.1",
"fastest-levenshtein": "1.0.16",
"http-status-codes": "2.3.0",
"lru-cache": "11.5.2",
"method-override": "3.0.0",
"morgan": "1.11.0",
"path-to-regexp": "8.4.2",
"picomatch": "4.0.7",
"type-fest": "5.8.0"
"@appium/support": "~8.0.0-beta.2",
"@appium/types": "~2.0.0-beta.2",
"async-lock": "^1.4.1",
"asyncbox": "^6.4.2",
"axios": "^1.19.0",
"lru-cache": "^11.5.2",
"path-to-regexp": "^8.4.2",
"picomatch": "^4.0.7"
},
"devDependencies": {
"@types/express": "^5.0.6"
},
"tsd": {
"compilerOptions": {
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import type {AddressInfo} from 'node:net';
import {describe, it} from 'node:test';
import {fs} from '@appium/support';
import {configureApp} from '../../../lib/basedriver/helpers/index.js';
describe('app download URL credentials', function () {
const cases = [
{name: 'plain credentials', username: 'user', password: 'pass'},
{name: 'encoded delimiters', username: 'user@example.com', password: 'p@ss:/ word'},
{name: 'literal percent escapes', username: 'user%40name', password: 'pass%2Fword'},
{name: 'Unicode credentials', username: 'ユーザー', password: 'パスワード'},
];
for (const {name, username, password} of cases) {
it(`authenticates with ${name}`, async function () {
const expectedAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const payload = 'synthetic apk payload';
let receivedAuth: string | undefined;
const server = http.createServer((req, res) => {
receivedAuth = req.headers.authorization;
if (receivedAuth !== expectedAuth) {
res.writeHead(401);
res.end();
return;
}
res.end(payload);
});
let appPath: string | undefined;
try {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const {port} = server.address() as AddressInfo;
const url = new URL(`http://127.0.0.1:${port}/auth.apk`);
url.username = encodeURIComponent(username);
url.password = encodeURIComponent(password);
appPath = await configureApp(url.href, '.apk');
assert.equal(receivedAuth, expectedAuth);
assert.equal(await fs.readFile(appPath, 'utf8'), payload);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
if (appPath) {
await fs.unlink(appPath);
}
}
});
}
});
@@ -4,11 +4,8 @@ import path from 'node:path';
import {after, afterEach, before, beforeEach, describe, it} from 'node:test';
import {getTestPort, TEST_HOST} from '@appium/driver-test-support';
import {fs, node} from '@appium/support';
import {fs, node, tempDir} from '@appium/support';
import {sleep} from 'asyncbox';
import contentDisposition from 'content-disposition';
import finalhandler from 'finalhandler';
import serveStatic from 'serve-static';
import {appUrlRules} from '../../../lib/basedriver/helpers/app-url-rules.js';
import {configureApp} from '../../../lib/basedriver/helpers/index.js';
@@ -24,6 +21,17 @@ function getFixture(file: string): string {
return path.resolve(FIXTURE_ROOT, file);
}
async function findNamedFiles(root: string, fileName: string): Promise<string[]> {
const found: string[] = [];
for (const entry of await fs.readdir(root)) {
const candidate = path.join(root, entry, fileName);
if (await fs.exists(candidate)) {
found.push(candidate);
}
}
return found;
}
describe('app download and configuration', function () {
describe('configureApp', function () {
it('should get the path for a local .app', async function () {
@@ -64,14 +72,17 @@ describe('app download and configuration', function () {
let server: HttpServerWithAsyncClose;
before(function () {
const serve = serveStatic(FIXTURE_ROOT, {
index: false,
setHeaders: (res, filePath) => {
res.setHeader('Content-Disposition', contentDisposition(filePath));
},
});
const httpServer = http.createServer(function (req, res) {
if (req.url?.includes('/cut/')) {
res.writeHead(200, {
'Content-Type': 'application/vnd.android.package-archive',
'Content-Length': '1000',
'Content-Disposition': 'attachment; filename="incomplete-download.apk"',
});
res.write(Buffer.alloc(64));
setTimeout(() => res.destroy(), 100);
return;
}
if (req.url?.indexOf('missing') !== -1) {
res.writeHead(404);
res.end();
@@ -93,14 +104,34 @@ describe('app download and configuration', function () {
res.end();
return;
}
// for testing zip file content types
const contentType = new URLSearchParams(new URL(req.url ?? '', 'http://localhost').search).get(
'content-type',
);
const reqUrl = new URL(req.url ?? '', 'http://localhost');
const contentType = reqUrl.searchParams.get('content-type');
if (contentType !== null) {
res.setHeader('content-type', contentType);
}
serve(req, res, finalhandler(req, res));
const disposition = reqUrl.searchParams.get('disposition');
if (disposition !== null) {
res.setHeader('Content-Disposition', disposition);
}
// serve a fixture file directly; no need for a full static file server here
const filePath = path.join(FIXTURE_ROOT, decodeURIComponent(reqUrl.pathname));
if (path.relative(FIXTURE_ROOT, filePath).startsWith('..')) {
res.writeHead(403);
res.end();
return;
}
fs.readFile(filePath)
.then((data) => {
if (!res.getHeader('Content-Disposition')) {
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
}
res.writeHead(200);
res.end(data);
})
.catch(() => {
res.writeHead(404);
res.end();
});
});
const close = httpServer.close.bind(httpServer);
// Replace close with async version; type assertion needed for method replacement
@@ -147,6 +178,37 @@ describe('app download and configuration', function () {
const contents = await fs.readFile(newAppPath, 'utf8');
assert.strictEqual(contents, 'this is not really an apk\n');
});
it('should use an unquoted Content-Disposition filename', async function () {
const disposition = encodeURIComponent('attachment; filename=from-header.apk');
const newAppPath = await configureApp(`${serverUrl}/FakeAndroidApp.apk?disposition=${disposition}`, '.apk');
assert.ok(newAppPath.includes('from-header.apk'));
const contents = await fs.readFile(newAppPath, 'utf8');
assert.strictEqual(contents, 'this is not really an apk\n');
});
it('should use an RFC 5987 filename* parameter', async function () {
const disposition = encodeURIComponent(`attachment; filename*=UTF-8''from-star.apk`);
const newAppPath = await configureApp(`${serverUrl}/FakeAndroidApp.apk?disposition=${disposition}`, '.apk');
assert.ok(newAppPath.includes('from-star.apk'));
const contents = await fs.readFile(newAppPath, 'utf8');
assert.strictEqual(contents, 'this is not really an apk\n');
});
it('should remove an incomplete app download', async function () {
const fileName = 'incomplete-download.apk';
const originalTmpDir = process.env.APPIUM_TMP_DIR;
const tempRoot = await tempDir.openDir();
process.env.APPIUM_TMP_DIR = tempRoot;
try {
await assert.rejects(configureApp(`${serverUrl}/cut/${fileName}`, '.apk'), /Cannot fetch the application/);
assert.deepEqual(await findNamedFiles(tempRoot, fileName), []);
} finally {
if (originalTmpDir === undefined) {
delete process.env.APPIUM_TMP_DIR;
} else {
process.env.APPIUM_TMP_DIR = originalTmpDir;
}
await fs.rimraf(tempRoot);
}
});
it('should handle zip file that cannot be downloaded', async function () {
await assert.rejects(configureApp(`${serverUrl}/missing/FakeIOSApp.app.zip`, '.app'));
});
-36
View File
@@ -1,10 +1,6 @@
import nodePath from 'node:path';
import {getTestPort, TEST_HOST} from '@appium/driver-test-support';
import {node, util} from '@appium/support';
import type {AppiumServer, Constraints, Driver, MethodMap, ServerArgs} from '@appium/types';
import {routeConfiguringFunction, server} from '../lib/index.js';
const BASE_DRIVER_MODULE_NAME = '@appium/base-driver';
@@ -35,38 +31,6 @@ export function resolveSourceSnapshotPath(testFilePath: string | undefined): str
return `${nodePath.join(root, 'test', relativePath).replace(/\.js$/, '.ts')}.snapshot`;
}
export async function createServer<T extends Driver<Constraints>>(
driver: T,
options: {
extraMethodMap?: MethodMap<T>;
hostname?: string;
cliArgs?: Partial<ServerArgs>;
port?: number;
} = {},
): Promise<{
port: number;
baseUrl: string;
setup: () => Promise<void>;
teardown: () => Promise<void>;
}> {
const port = options.port ?? (await getTestPort());
const baseUrl = `http://${TEST_HOST}:${port}`;
let appiumServer: AppiumServer | undefined;
const setup = async () => {
appiumServer = await server({
routeConfiguringFunction: routeConfiguringFunction(driver),
port,
extraMethodMap: options.extraMethodMap,
hostname: options.hostname,
cliArgs: options.cliArgs,
});
};
const teardown = async () => {
await appiumServer?.close();
};
return {port, baseUrl, setup, teardown};
}
/**
* Build Appium server URLs for tests.
*
@@ -182,10 +182,10 @@ describe('caps', function () {
assert.deepStrictEqual(parseCaps(caps as TestW3CCaps).allFirstMatchCaps, [{}]);
});
it('returns invalid argument error if "firstMatch" is not an array and is not undefined (3.2)', function () {
for (const arg of [null, 1, true, 'string']) {
it('returns invalid argument error if "firstMatch" is not a non-empty array and is not undefined (3.2)', function () {
for (const arg of [null, 1, true, 'string', []]) {
caps.firstMatch = arg;
assert.throws(() => parseCaps(caps as TestW3CCaps), /must be a JSON array or undefined/);
assert.throws(() => parseCaps(caps as TestW3CCaps), /must be a non-empty JSON array or undefined/);
}
});
@@ -190,6 +190,23 @@ describe('Desired Capabilities', function () {
assert.strictEqual((sessionCaps.capabilities as Record<string, unknown>).noReset, true);
});
it('should reject noReset and fullReset set together', async function () {
await assert.rejects(
d.createSession({
alwaysMatch: {platformName: 'iOS', 'appium:noReset': true, 'appium:fullReset': true},
firstMatch: [{}],
} as unknown as TestW3CCaps),
{name: 'SessionNotCreatedError', message: /mutually exclusive/},
);
assert.equal(d.sessionId, null);
const [sessionId] = await d.createSession({
alwaysMatch: {platformName: 'iOS', 'appium:fullReset': true},
} as unknown as TestW3CCaps);
assert.equal(d.sessionId, sessionId);
assert.equal(d.opts.fullReset, true);
assert.equal(d.opts.noReset, false);
});
it('should allow a string "true" in string capabilities', async function () {
await d.createSession({
alwaysMatch: {platformName: 'iOS', 'appium:language': 'true'},
@@ -225,6 +242,32 @@ describe('Desired Capabilities', function () {
assert.strictEqual((sessionCaps.capabilities as Record<string, unknown>).newCommandTimeout, 1.1);
});
it('should keep the default timeout when newCommandTimeout is null', async function () {
const defaultMs = d.newCommandTimeoutMs;
await d.createSession({
alwaysMatch: {platformName: 'iOS', 'appium:newCommandTimeout': null},
firstMatch: [{}],
} as unknown as TestW3CCaps);
assert.strictEqual(d.newCommandTimeoutMs, defaultMs);
});
it('should keep the default timeout when newCommandTimeout is blank', async function () {
const defaultMs = d.newCommandTimeoutMs;
await d.createSession({
alwaysMatch: {platformName: 'iOS', 'appium:newCommandTimeout': ''},
firstMatch: [{}],
} as unknown as TestW3CCaps);
assert.strictEqual(d.newCommandTimeoutMs, defaultMs);
});
it('should disable the timeout when newCommandTimeout is 0', async function () {
await d.createSession({
alwaysMatch: {platformName: 'iOS', 'appium:newCommandTimeout': 0},
firstMatch: [{}],
} as unknown as TestW3CCaps);
assert.strictEqual(d.newCommandTimeoutMs, 0);
});
it('should allow a string "1" in string capabilities', async function () {
await d.createSession({
alwaysMatch: {platformName: 'iOS', 'appium:language': '1'},
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import {describe, it} from 'node:test';
import {BaseDriver} from '../../../../lib/index.js';
describe('BiDi user-context subscriptions', function () {
it('should reject unsupported user contexts without broadening existing subscriptions', async function () {
const driver = new BaseDriver();
await driver.bidiSubscribe(['log.entryAdded'], ['existing-context']);
await assert.rejects(
driver.executeBidiCommand('session.subscribe', {events: ['log.entryAdded'], userContexts: ['user-1']}),
{error: 'unsupported operation'},
);
assert.deepEqual(driver.bidiEventSubs, {'log.entryAdded': ['existing-context']});
});
for (const userContexts of [[], [''], 'user-1', [null]]) {
it(`should reject malformed userContexts: ${JSON.stringify(userContexts)}`, async function () {
const driver = new BaseDriver();
await assert.rejects(driver.executeBidiCommand('session.subscribe', {events: ['log.entryAdded'], userContexts}), {
error: 'invalid argument',
});
assert.deepEqual(driver.bidiEventSubs, {});
});
}
it('should reject simultaneous browsing and user context scopes', async function () {
const driver = new BaseDriver();
await assert.rejects(
driver.executeBidiCommand('session.subscribe', {
events: ['log.entryAdded'],
contexts: ['context-1'],
userContexts: ['user-1'],
}),
{error: 'invalid argument'},
);
assert.deepEqual(driver.bidiEventSubs, {});
});
it('should still pass userContexts to a driver-specific subscription implementation', async function () {
const driver = new BaseDriver();
const received: unknown[][] = [];
Object.assign(driver, {
bidiSubscribe: async (...args: unknown[]) => {
received.push(args);
return {subscription: 'driver-owned'};
},
});
await driver.executeBidiCommand('session.subscribe', {events: ['log.entryAdded'], userContexts: ['user-1']});
assert.deepEqual(received, [[['log.entryAdded'], undefined, ['user-1']]]);
});
it('should retain global and browsing-context subscriptions without userContexts', async function () {
const driver = new BaseDriver();
await driver.executeBidiCommand('session.subscribe', {events: ['log.entryAdded']});
await driver.executeBidiCommand('session.subscribe', {events: ['browsingContext.load'], contexts: ['context-1']});
assert.deepEqual(driver.bidiEventSubs, {'log.entryAdded': [''], 'browsingContext.load': ['context-1']});
});
});
@@ -13,6 +13,98 @@ describe('bidi commands -', function () {
});
describe('bidiUnsubscribe', function () {
it('should return unique IDs and remove only the selected overlapping subscription', async function () {
const first = await driver.executeBidiCommand('session.subscribe', {events: ['log.entryAdded'], contexts: ['a']});
const second = await driver.executeBidiCommand('session.subscribe', {
events: ['log.entryAdded'],
contexts: ['a', 'b'],
});
assert.equal(typeof first.subscription, 'string');
assert.notEqual(first.subscription, second.subscription);
assert.deepEqual(
await driver.executeBidiCommand('session.unsubscribe', {subscriptions: [first.subscription]}),
{},
);
assert.deepEqual(driver.bidiEventSubs, {'log.entryAdded': ['a', 'b']});
await driver.executeBidiCommand('session.unsubscribe', {subscriptions: [second.subscription]});
assert.deepEqual(driver.bidiEventSubs, {});
});
it('should validate every ID before removing any subscriptions', async function () {
const {subscription} = await driver.bidiSubscribe(['log.entryAdded']);
await assert.rejects(
driver.executeBidiCommand('session.unsubscribe', {subscriptions: [subscription, 'missing']}),
/Unknown subscription/,
);
assert.deepEqual(driver.bidiEventSubs, {'log.entryAdded': ['']});
await driver.executeBidiCommand('session.unsubscribe', {subscriptions: [subscription, subscription]});
assert.deepEqual(driver.bidiEventSubs, {});
});
it('should preserve the legacy event form without confusing events and IDs', async function () {
const {subscription} = await driver.bidiSubscribe(['log.entryAdded'], ['a', 'b']);
await driver.executeBidiCommand('session.unsubscribe', {events: ['log.entryAdded'], contexts: ['a']});
assert.deepEqual(driver.bidiEventSubs, {'log.entryAdded': ['b']});
await driver.executeBidiCommand('session.unsubscribe', {subscriptions: [subscription]});
assert.deepEqual(driver.bidiEventSubs, {});
});
it('should reject mixed ID and event requests before mutating subscriptions', async function () {
const {subscription} = await driver.bidiSubscribe(['log.entryAdded']);
for (const extra of [{events: ['log.entryAdded']}, {contexts: ['a']}]) {
await assert.rejects(
driver.executeBidiCommand('session.unsubscribe', {subscriptions: [subscription], ...extra}),
/cannot be combined/,
);
}
assert.deepEqual(driver.bidiEventSubs, {'log.entryAdded': ['']});
});
it('should reject mixed arguments in direct ID-based unsubscribe calls without removing subscriptions', async function () {
const {subscription} = await driver.bidiSubscribe(['log.entryAdded']);
for (const contexts of [[], [''], ['a']]) {
await assert.rejects(driver.bidiUnsubscribe(undefined, contexts, [subscription]), /cannot be combined/);
}
await assert.rejects(driver.bidiUnsubscribe(['log.entryAdded'], undefined, [subscription]), /cannot be combined/);
assert.deepEqual(driver.bidiEventSubs, {'log.entryAdded': ['']});
await driver.bidiUnsubscribe(undefined, undefined, [subscription]);
assert.deepEqual(driver.bidiEventSubs, {});
});
it('should default legacy unsubscribe to the global context', async function () {
await driver.bidiSubscribe(['log.entryAdded']);
await driver.bidiUnsubscribe(['log.entryAdded']);
assert.deepEqual(driver.bidiEventSubs, {});
});
it('should safely aggregate event names matching Object prototype properties', async function () {
const events = ['__proto__', 'constructor', 'hasOwnProperty'];
const first = await driver.bidiSubscribe(events, ['a']);
const second = await driver.bidiSubscribe(events, ['a', 'b']);
assert.deepEqual(driver.bidiEventSubs, Object.fromEntries(events.map((event) => [event, ['a', 'b']])));
assert.equal(Object.getPrototypeOf(driver.bidiEventSubs), Object.prototype);
await driver.bidiUnsubscribe(undefined, undefined, [second.subscription]);
assert.deepEqual(driver.bidiEventSubs, Object.fromEntries(events.map((event) => [event, ['a']])));
await driver.bidiUnsubscribe(undefined, undefined, [first.subscription]);
assert.deepEqual(driver.bidiEventSubs, {});
});
it('should clear subscription IDs when the session is deleted', async function () {
const {subscription} = await driver.bidiSubscribe(['log.entryAdded']);
await driver.deleteSession();
assert.deepEqual(driver.bidiEventSubs, {});
await assert.rejects(
driver.executeBidiCommand('session.unsubscribe', {subscriptions: [subscription]}),
/Unknown subscription/,
);
});
for (const subscriptions of [[], [''], 'id', [null]]) {
it(`should reject malformed subscription IDs: ${JSON.stringify(subscriptions)}`, async function () {
await assert.rejects(driver.executeBidiCommand('session.unsubscribe', {subscriptions}), /non-empty array/);
});
}
it('should not throw when the event was never subscribed', async function () {
await driver.bidiUnsubscribe(['log.entryAdded'], ['']);
assert.deepStrictEqual(driver.bidiEventSubs, {});
@@ -24,4 +116,31 @@ describe('bidi commands -', function () {
assert.deepStrictEqual(driver.bidiEventSubs, {});
});
});
describe('bidiSubscribe', function () {
it('should keep previously subscribed contexts when the same event is subscribed again', async function () {
await driver.bidiSubscribe(['log.entryAdded'], ['ctx-a']);
await driver.bidiSubscribe(['log.entryAdded'], ['ctx-b']);
assert.deepStrictEqual(driver.bidiEventSubs, {
'log.entryAdded': ['ctx-a', 'ctx-b'],
});
});
it('should not duplicate a context that is already subscribed', async function () {
await driver.bidiSubscribe(['log.entryAdded'], ['ctx-a']);
await driver.bidiSubscribe(['log.entryAdded'], ['ctx-a', 'ctx-b']);
assert.deepStrictEqual(driver.bidiEventSubs, {
'log.entryAdded': ['ctx-a', 'ctx-b'],
});
});
it('should not mix contexts across different events', async function () {
await driver.bidiSubscribe(['log.entryAdded'], ['ctx-a']);
await driver.bidiSubscribe(['browsingContext.load'], ['ctx-b']);
assert.deepStrictEqual(driver.bidiEventSubs, {
'log.entryAdded': ['ctx-a'],
'browsingContext.load': ['ctx-b'],
});
});
});
});
@@ -5,6 +5,23 @@ import type {InitialOpts} from '@appium/types';
import {BaseDriver} from '../../../lib/index.js';
class SlowCommandDriver extends BaseDriver<any> {
async slowCommand(): Promise<string> {
await new Promise((resolve) => setTimeout(resolve, 100));
return 'slow-done';
}
async quickCommand(): Promise<string> {
return 'quick-done';
}
}
class CustomExemptDriver extends SlowCommandDriver {
override get queueExemptCommands(): ReadonlySet<string> {
return new Set([...super.queueExemptCommands, 'quickCommand']);
}
}
describe('BaseDriver', function () {
describe('constructor', function () {
it('should initialize "opts"', function () {
@@ -12,4 +29,131 @@ describe('BaseDriver', function () {
assert.ok(driver.opts);
});
});
describe('executeCommand', function () {
it('should only time out after the last queued command finishes', async function () {
const driver = new SlowCommandDriver({} as InitialOpts);
driver.sessionId = 'queued-timeout-test';
driver.newCommandTimeoutMs = 50;
try {
assert.deepEqual(
await Promise.all([driver.executeCommand('slowCommand'), driver.executeCommand('slowCommand')]),
['slow-done', 'slow-done'],
);
assert.equal(driver.sessionId, 'queued-timeout-test');
const shutdown = new Promise<void>((resolve) => driver.eventEmitter.once('onUnexpectedShutdown', resolve));
await shutdown;
// The shutdown event is emitted before deleteSession completes.
await new Promise((resolve) => setImmediate(resolve));
assert.equal(driver.sessionId, null);
} finally {
await driver.clearNewCommandTimeout();
}
});
it('should not make getStatus wait behind a command already in the queue', async function () {
const driver = new SlowCommandDriver({} as InitialOpts);
const order: string[] = [];
const slowPromise = driver.executeCommand('slowCommand').then((res) => {
order.push('slowCommand');
return res;
});
// let the slow command actually start and occupy the queue before requesting status
await new Promise((resolve) => setImmediate(resolve));
const status = await driver.executeCommand('getStatus');
order.push('getStatus');
assert.deepEqual(status, {});
assert.equal(await slowPromise, 'slow-done');
assert.deepEqual(order, ['getStatus', 'slowCommand']);
await driver.clearNewCommandTimeout();
});
it('should make a regular command wait behind a command already in the queue', async function () {
const driver = new SlowCommandDriver({} as InitialOpts);
const order: string[] = [];
const slowPromise = driver.executeCommand('slowCommand').then((res) => {
order.push('slowCommand');
return res;
});
await new Promise((resolve) => setImmediate(resolve));
const quickResult = await driver.executeCommand('quickCommand').then((res) => {
order.push('quickCommand');
return res;
});
assert.equal(quickResult, 'quick-done');
assert.deepEqual(order, ['slowCommand', 'quickCommand']);
assert.equal(await slowPromise, 'slow-done');
await driver.clearNewCommandTimeout();
});
it('should allow a subclass to add its own queue-exempt commands', async function () {
const driver = new CustomExemptDriver({} as InitialOpts);
const order: string[] = [];
const slowPromise = driver.executeCommand('slowCommand').then((res) => {
order.push('slowCommand');
return res;
});
await new Promise((resolve) => setImmediate(resolve));
const quickResult = await driver.executeCommand('quickCommand').then((res) => {
order.push('quickCommand');
return res;
});
assert.equal(quickResult, 'quick-done');
assert.equal(await slowPromise, 'slow-done');
assert.deepEqual(order, ['quickCommand', 'slowCommand']);
await driver.clearNewCommandTimeout();
});
it('should not restart the new command timeout while another command is still in flight', async function () {
const driver = new SlowCommandDriver({} as InitialOpts);
// short enough that a wrongly-restarted timer fires well before slowCommand finishes
driver.newCommandTimeoutMs = 50;
const slowPromise = driver.executeCommand('slowCommand');
// let slowCommand start and settle in, then run an exempt command while it's still pending
await new Promise((resolve) => setTimeout(resolve, 20));
await driver.executeCommand('getStatus');
// if getStatus wrongly restarted the timer, slowCommand would be rejected by an
// unexpected shutdown well before its own 100ms completes
assert.equal(await slowPromise, 'slow-done');
await driver.clearNewCommandTimeout();
});
it('should not arm the idle timer when an unknown command arrives during another command', async function () {
const driver = new SlowCommandDriver({} as InitialOpts);
driver.newCommandTimeoutMs = 50;
const slowPromise = driver.executeCommand('slowCommand');
await new Promise((resolve) => setTimeout(resolve, 20));
await assert.rejects(driver.executeCommand('notARealCommand'), /not yet been implemented/);
assert.equal(await slowPromise, 'slow-done');
await driver.clearNewCommandTimeout();
});
it('should arm the idle timer after an unknown command when the driver is idle', async function () {
const driver = new BaseDriver({} as InitialOpts);
driver.newCommandTimeoutMs = 30;
let expired = false;
driver.onUnexpectedShutdown(() => {
expired = true;
});
await assert.rejects(driver.executeCommand('notARealCommand'), /not yet been implemented/);
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(expired, true);
await driver.clearNewCommandTimeout();
});
});
});
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import {describe, it} from 'node:test';
import {duplicateKeys, isPackageOrBundle, parseCapsArray} from '../../../lib/basedriver/helpers/index.js';
import {
duplicateKeys,
filenameFromContentDisposition,
isPackageOrBundle,
parseCapsArray,
} from '../../../lib/basedriver/helpers/index.js';
describe('helpers', function () {
describe('#isPackageOrBundle', function () {
@@ -118,3 +123,27 @@ describe('parseCapsArray', function () {
assert.throws(() => parseCapsArray(`['*']`));
});
});
describe('filenameFromContentDisposition', function () {
const cases: [desc: string, header: string, expected: string | undefined][] = [
['should read a quoted filename', 'attachment; filename="quoted-app.apk"', 'quoted-app.apk'],
['should read an unquoted filename', 'attachment; filename=unquoted-app.apk', 'unquoted-app.apk'],
[
'should prefer RFC 5987 filename* over filename',
`attachment; filename="wrong.apk"; filename*=UTF-8''from-star.apk`,
'from-star.apk',
],
['should decode a percent-encoded filename*', `attachment; filename*=UTF-8''My%20App.apk`, 'My App.apk'],
['should not let an unquoted token swallow later parameters', 'attachment; filename=app.apk; size=42', 'app.apk'],
['should keep a quoted filename containing a semicolon', 'attachment; filename="a;b.apk"', 'a;b.apk'],
['should ignore a parameter which merely ends with filename', 'attachment; x-filename=sneaky.apk', undefined],
['should return undefined for an empty quoted filename', 'attachment; filename=""', undefined],
['should return undefined when filename* is not decodable', `attachment; filename*=UTF-8''%E0%A4%A`, undefined],
['should return undefined when the header has no filename', 'attachment', undefined],
];
for (const [desc, header, expected] of cases) {
it(desc, function () {
assert.strictEqual(filenameFromContentDisposition(header), expected);
});
}
});
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import {describe, it} from 'node:test';
import type {Constraints} from '@appium/types';
import {BaseDriver} from '../../../lib/index.js';
class QueuedDriver extends BaseDriver<Constraints> {
release!: () => void;
readonly blocked = new Promise<void>((resolve) => {
this.release = resolve;
});
calls = 0;
async blockedCommand() {
await this.blocked;
}
async countedCommand() {
return ++this.calls;
}
}
describe('queued command cancellation', function () {
it('should reject commands behind deleteSession without executing them', async function () {
const driver = new QueuedDriver();
driver.newCommandTimeoutMs = 0;
driver.sessionId = 'test';
const results = Promise.allSettled([
driver.executeCommand('blockedCommand'),
driver.executeCommand('deleteSession'),
driver.executeCommand('countedCommand'),
driver.executeCommand('countedCommand'),
]);
await new Promise((resolve) => setImmediate(resolve));
driver.release();
const [first, deletion, ...cancelled] = await results;
assert.equal(first.status, 'fulfilled');
assert.equal(deletion.status, 'fulfilled');
for (const result of cancelled) {
assert.equal(result.status, 'rejected');
if (result.status === 'rejected') {
assert.equal(result.reason.error, 'invalid session id');
}
}
assert.equal(driver.calls, 0);
// Cancelled queue entries must drain without damaging the lock for subsequent work.
assert.equal(await driver.executeCommand('countedCommand'), 1);
});
it('should reject waiting commands immediately when deletion occurs outside the queue', async function () {
const driver = new QueuedDriver();
driver.newCommandTimeoutMs = 0;
driver.sessionId = 'test';
const running = driver.executeCommand('blockedCommand');
const cancelled = assert.rejects(driver.executeCommand('countedCommand'), /session was deleted/);
await new Promise((resolve) => setImmediate(resolve));
await driver.deleteSession();
await cancelled;
driver.release();
await running;
assert.equal(await driver.executeCommand('countedCommand'), 1);
});
});
@@ -2,8 +2,7 @@ import assert from 'node:assert/strict';
import path from 'node:path';
import {describe, it} from 'node:test';
import {StatusCodes as HTTPStatusCodes} from 'http-status-codes';
import {HTTPStatusCodes} from '../../../lib/helpers/http-status-codes.js';
import {errorFromW3CJsonCode, errors, isErrorType} from '../../../lib/index.js';
import {BadParametersError, getResponseForW3CError} from '../../../lib/protocol/errors.js';
+15
View File
@@ -3,6 +3,21 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [4.0.0-beta.2](https://github.com/appium/appium/compare/@appium/base-plugin@4.0.0-beta.1...@appium/base-plugin@4.0.0-beta.2) (2026-10-05)
### Bug Fixes
* use native ESM entrypoint detection ([#22868](https://github.com/appium/appium/issues/22868)) ([e391bb2](https://github.com/appium/appium/commit/e391bb249fec5f700a6d63a747ff921a80689fb5))
## [4.0.0-beta.1](https://github.com/appium/appium/compare/@appium/base-plugin@4.0.0-beta.0...@appium/base-plugin@4.0.0-beta.1) (2026-09-27)
**Note:** Version bump only for package @appium/base-plugin
## [4.0.0-beta.0](https://github.com/appium/appium/compare/@appium/base-plugin@3.3.4...@appium/base-plugin@4.0.0-beta.0) (2026-09-19)
### ⚠ BREAKING CHANGES
+2 -10
View File
@@ -1,14 +1,6 @@
import {realpathSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
export {BasePlugin} from './plugin.js';
// Handle smoke test flag. realpath() both sides so this still matches when invoked through a
// bin symlink, since `import.meta.url` resolves symlinks but `process.argv[1]` does not.
if (
process.argv[1] &&
fileURLToPath(import.meta.url) === realpathSync(process.argv[1]) &&
process.argv[2] === '--smoke-test'
) {
// Handle the smoke test flag only when invoked directly.
if (import.meta.main && process.argv[2] === '--smoke-test') {
process.exit(0);
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@appium/base-plugin",
"version": "4.0.0-beta.0",
"version": "4.0.0-beta.2",
"gitHead": "f7b20335eab4022e5cbbb627ec86866a994444f8",
"description": "The base plugin used to create Appium plugins",
"keywords": [
@@ -52,9 +52,9 @@
"test:unit": "node --test --experimental-test-module-mocks --test-concurrency=1 --enable-source-maps \"./build/test/unit/**/*.spec.js\""
},
"dependencies": {
"@appium/base-driver": "11.0.0-beta.0",
"@appium/support": "8.0.0-beta.0",
"@appium/types": "2.0.0-beta.0"
"@appium/base-driver": "~11.0.0-beta.2",
"@appium/support": "~8.0.0-beta.2",
"@appium/types": "~2.0.0-beta.2"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0",
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [4.0.0-beta.2](https://github.com/appium/appium/compare/@appium/docutils@4.0.0-beta.1...@appium/docutils@4.0.0-beta.2) (2026-10-05)
**Note:** Version bump only for package @appium/docutils
## [4.0.0-beta.1](https://github.com/appium/appium/compare/@appium/docutils@4.0.0-beta.0...@appium/docutils@4.0.0-beta.1) (2026-09-27)
**Note:** Version bump only for package @appium/docutils
## [4.0.0-beta.0](https://github.com/appium/appium/compare/@appium/docutils@3.0.0...@appium/docutils@4.0.0-beta.0) (2026-09-19)
### ⚠ BREAKING CHANGES
+2 -8
View File
@@ -6,8 +6,8 @@
import path from 'node:path';
import {fs, util} from '@appium/support';
import type {JsonValue, PackageJson} from '@appium/types';
import {exec} from 'teen_process';
import type {JsonValue} from 'type-fest';
import * as YAML from 'yaml';
import {
@@ -21,13 +21,7 @@ import {
import {DocutilsError} from './error.js';
import {getLogger} from './logger.js';
import type {MkDocsYml} from './model.js';
import {
findPackageRoot,
type NormalizedPackageJson,
type PackageJson,
mergeDefaultsDeep,
readPackage,
} from './utils/index.js';
import {findPackageRoot, type NormalizedPackageJson, mergeDefaultsDeep, readPackage} from './utils/index.js';
const log = getLogger('fs');
+1 -1
View File
@@ -4,8 +4,8 @@
* @module
*/
import type {Simplify} from '@appium/types';
import {exec} from 'teen_process';
import type {Simplify} from 'type-fest';
import * as YAML from 'yaml';
import {NAME_MKDOCS_YML, PIP_ENV_VARS, REQUIREMENTS_TXT_PATH} from './constants.js';
+1 -1
View File
@@ -4,7 +4,7 @@
* @module
*/
import type {Jsonify, JsonValue} from 'type-fest';
import type {Jsonify, JsonValue} from '@appium/types';
/**
* The `nav` prop of an `mkdocs.yml` file
+1 -1
View File
@@ -6,8 +6,8 @@
import path from 'node:path';
import {fs, util} from '@appium/support';
import type {JsonObject, JsonValue} from '@appium/types';
import {createPatch} from 'diff';
import type {JsonObject, JsonValue} from 'type-fest';
import {NAME_ERR_ENOENT} from './constants.js';
import {DocutilsError} from './error.js';
+1 -1
View File
@@ -2,7 +2,7 @@ export {argify, kebabCase} from './cli.js';
export {isStringArray, mergeDefaultsDeep} from './object.js';
export type {TupleToObject} from './object.js';
export {findPackageRoot, findPackageRootSync, readPackage} from './package-json.js';
export type {NormalizedPackageJson, NormalizeOptions, PackageJson, ReadPackageOptions} from './package-json.js';
export type {NormalizedPackageJson, NormalizeOptions, ReadPackageOptions} from './package-json.js';
export {relative} from './path.js';
export {execWithErrorHandling, spawnBackgroundProcess} from './process.js';
export type {SpawnBackgroundProcessOpts} from './process.js';
+1 -3
View File
@@ -2,10 +2,8 @@ import nodeFs from 'node:fs';
import path from 'node:path';
import {fs} from '@appium/support';
import type {PackageJson} from '@appium/types';
import normalizePackageData from 'normalize-package-data';
import type {PackageJson as TypeFestPackageJson} from 'type-fest';
export type PackageJson = TypeFestPackageJson;
export type NormalizedPackageJson = PackageJson & {
name: string;
+13 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@appium/docutils",
"version": "4.0.0-beta.0",
"version": "4.0.0-beta.2",
"gitHead": "f7b20335eab4022e5cbbb627ec86866a994444f8",
"description": "Documentation generation utilities for Appium and related projects",
"keywords": [
@@ -58,16 +58,18 @@
"start": "node ./build/lib/cli/index.js"
},
"dependencies": {
"@appium/support": "8.0.0-beta.0",
"consola": "3.4.2",
"diff": "9.0.0",
"lilconfig": "3.1.3",
"normalize-package-data": "9.0.0",
"teen_process": "4.2.1",
"type-fest": "5.8.0",
"yaml": "2.9.0",
"yargs": "18.1.0",
"yargs-parser": "22.0.0"
"@appium/support": "~8.0.0-beta.2",
"consola": "^3.4.2",
"diff": "^9.0.0",
"lilconfig": "^3.1.3",
"normalize-package-data": "^9.0.0",
"teen_process": "^4.2.1",
"yaml": "^2.9.0",
"yargs": "^18.1.0",
"yargs-parser": "^22.0.0"
},
"devDependencies": {
"@appium/types": "~2.0.0-beta.2"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0",
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [4.0.0-beta.2](https://github.com/appium/appium/compare/@appium/driver-test-support@4.0.0-beta.1...@appium/driver-test-support@4.0.0-beta.2) (2026-10-05)
**Note:** Version bump only for package @appium/driver-test-support
## [4.0.0-beta.1](https://github.com/appium/appium/compare/@appium/driver-test-support@4.0.0-beta.0...@appium/driver-test-support@4.0.0-beta.1) (2026-09-27)
**Note:** Version bump only for package @appium/driver-test-support
## [4.0.0-beta.0](https://github.com/appium/appium/compare/@appium/driver-test-support@3.0.0...@appium/driver-test-support@4.0.0-beta.0) (2026-09-19)
### ⚠ BREAKING CHANGES
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@appium/driver-test-support",
"version": "4.0.0-beta.0",
"version": "4.0.0-beta.2",
"gitHead": "f7b20335eab4022e5cbbb627ec86866a994444f8",
"description": "Test utilities for Appium drivers",
"keywords": [
@@ -53,7 +53,7 @@
"test:unit": "node --test --enable-source-maps \"./build/test/unit/**/*.spec.js\""
},
"devDependencies": {
"@appium/types": "2.0.0-beta.0"
"@appium/types": "~2.0.0-beta.2"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0",
@@ -3,6 +3,29 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [8.0.0-beta.3](https://github.com/appium/appium/compare/@appium/execute-driver-plugin@8.0.0-beta.2...@appium/execute-driver-plugin@8.0.0-beta.3) (2026-10-05)
**Note:** Version bump only for package @appium/execute-driver-plugin
## [8.0.0-beta.2](https://github.com/appium/appium/compare/@appium/execute-driver-plugin@8.0.0-beta.1...@appium/execute-driver-plugin@8.0.0-beta.2) (2026-09-27)
**Note:** Version bump only for package @appium/execute-driver-plugin
## [8.0.0-beta.1](https://github.com/appium/appium/compare/@appium/execute-driver-plugin@8.0.0-beta.0...@appium/execute-driver-plugin@8.0.0-beta.1) (2026-09-19)
### Bug Fixes
* **deps:** catch up peerDependencies to published beta.0; drop bootstrap_packages ([#22793](https://github.com/appium/appium/issues/22793)) ([b09efc3](https://github.com/appium/appium/commit/b09efc31c6967bbcf2c4314db9d2a0b6454fa8fb))
## [8.0.0-beta.0](https://github.com/appium/appium/compare/@appium/execute-driver-plugin@7.0.0...@appium/execute-driver-plugin@8.0.0-beta.0) (2026-09-19)
### ⚠ BREAKING CHANGES
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@appium/execute-driver-plugin",
"version": "8.0.0-beta.0",
"version": "8.0.0-beta.3",
"gitHead": "f7b20335eab4022e5cbbb627ec86866a994444f8",
"description": "Plugin for batching and executing Appium driver commands",
"keywords": [
@@ -49,14 +49,14 @@
"test:e2e": "node --test --enable-source-maps --test-force-exit --test-concurrency=1 --test-timeout=20000 \"./build/test/e2e/**/*.spec.js\""
},
"dependencies": {
"@appium/support": "8.0.0-beta.0",
"@appium/support": "8.0.0-beta.2",
"webdriverio": "9.31.2"
},
"devDependencies": {
"@appium/types": "2.0.0-beta.0"
"@appium/types": "2.0.0-beta.2"
},
"peerDependencies": {
"appium": "^3.0.0-beta.0"
"appium": "^4.0.0-beta.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0",
+32
View File
@@ -3,6 +3,38 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [7.0.0-beta.3](https://github.com/appium/appium/compare/@appium/fake-driver@7.0.0-beta.2...@appium/fake-driver@7.0.0-beta.3) (2026-10-05)
**Note:** Version bump only for package @appium/fake-driver
## [7.0.0-beta.2](https://github.com/appium/appium/compare/@appium/fake-driver@7.0.0-beta.1...@appium/fake-driver@7.0.0-beta.2) (2026-09-27)
### ⚠ BREAKING CHANGES
* **base-driver:** @appium/base-driver no longer exports server, normalizeBasePath, configureServer, ServerOpts, ConfigureServerOpts, ConfigureHttpOpts, or StartServerOpts, and no longer depends on express/morgan/body-parser/ method-override/path-to-regexp. Use the new @appium/http-server package instead.
* **base-driver:** appium/driver.js and driver.d.ts no longer re-export server/normalizeBasePath/configureServer/ServerOpts (or their types), and no longer use a blanket export *. Import from @appium/http-server directly if you were relying on this undocumented path.
* **base-driver:** exempt getStatus from the commands queue by default (#22796)
### Features
* **base-driver:** exempt getStatus from the commands queue by default ([#22796](https://github.com/appium/appium/issues/22796)) ([22a00c7](https://github.com/appium/appium/commit/22a00c7a553d73fc4c57711bb56985a51047507c))
### Code Refactoring
* **base-driver:** extract the HTTP/Express server into @appium/http-server ([#22800](https://github.com/appium/appium/issues/22800)) ([225a1c6](https://github.com/appium/appium/commit/225a1c6d9d24fa2696699d4b61436a49b847d666))
## [7.0.0-beta.1](https://github.com/appium/appium/compare/@appium/fake-driver@7.0.0-beta.0...@appium/fake-driver@7.0.0-beta.1) (2026-09-19)
### Bug Fixes
* **deps:** catch up peerDependencies to published beta.0; drop bootstrap_packages ([#22793](https://github.com/appium/appium/issues/22793)) ([b09efc3](https://github.com/appium/appium/commit/b09efc31c6967bbcf2c4314db9d2a0b6454fa8fb))
## [7.0.0-beta.0](https://github.com/appium/appium/compare/@appium/fake-driver@6.2.3...@appium/fake-driver@7.0.0-beta.0) (2026-09-19)
### ⚠ BREAKING CHANGES

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