mirror of
https://github.com/appium/appium.git
synced 2026-11-12 05:14:19 +08:00
Compare commits
168
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e303649a42 | ||
|
|
d2483b8bf4 | ||
|
|
b78454ab3e | ||
|
|
83b7835733 | ||
|
|
33d866e575 | ||
|
|
0b104f6bf9 | ||
|
|
93115d1957 | ||
|
|
ac01aa83ad | ||
|
|
0e91fb756b | ||
|
|
8b62a7971a | ||
|
|
a73644fb4f | ||
|
|
33def2cc0b | ||
|
|
b7b09f750f | ||
|
|
b129cbbcd5 | ||
|
|
d6ef7e3f12 | ||
|
|
b25fc20c51 | ||
|
|
74d1ee6d17 | ||
|
|
bcf1c57999 | ||
|
|
5e644a392f | ||
|
|
ff458d7cac | ||
|
|
812ca2a9af | ||
|
|
6323df1d73 | ||
|
|
4eb4d7f317 | ||
|
|
506bfeeb9a | ||
|
|
0df8834638 | ||
|
|
add1615607 | ||
|
|
8840f9f932 | ||
|
|
926a0a5949 | ||
|
|
db72249873 | ||
|
|
f71d980779 | ||
|
|
d82d496f9e | ||
|
|
fe24f527a4 | ||
|
|
5875b94fc4 | ||
|
|
62db5f0b20 | ||
|
|
ef8a8e1934 | ||
|
|
8709844b21 | ||
|
|
464c271fbc | ||
|
|
8db8e5748e | ||
|
|
8e8c00e6db | ||
|
|
c6e42f3fb9 | ||
|
|
fbeb496142 | ||
|
|
9f4a11e719 | ||
|
|
8b7bf30d43 | ||
|
|
835119d1ce | ||
|
|
0b83e77e7a | ||
|
|
454c22f4c2 | ||
|
|
a38539deaf | ||
|
|
e8ee71685b | ||
|
|
8fcdcb30b0 | ||
|
|
ec7de4768f | ||
|
|
cd0d8f14e9 | ||
|
|
3516e50ce6 | ||
|
|
dafc5985bb | ||
|
|
02443f98d6 | ||
|
|
b4097b54d4 | ||
|
|
b67de03966 | ||
|
|
2b39493e98 | ||
|
|
e916673e32 | ||
|
|
76ff5aa69a | ||
|
|
b1b313b989 | ||
|
|
658f88d8c1 | ||
|
|
3999208fc0 | ||
|
|
1f945716a0 | ||
|
|
0a3ad48419 | ||
|
|
17be28c750 | ||
|
|
746073148f | ||
|
|
73ef2f7d43 | ||
|
|
6fb07a5dbc | ||
|
|
5dd80daf63 | ||
|
|
05e36962e6 | ||
|
|
eaa824f601 | ||
|
|
6cb62d21e8 | ||
|
|
8254a7a3b5 | ||
|
|
83e99bc752 | ||
|
|
147c0bcac3 | ||
|
|
ddd4251108 | ||
|
|
b19bd7878b | ||
|
|
91446df648 | ||
|
|
d5b316031e | ||
|
|
737cf77b6e | ||
|
|
c35a12a51d | ||
|
|
5c2aafa208 | ||
|
|
4422d99d45 | ||
|
|
ce50dd7fa7 | ||
|
|
f06226b7bb | ||
|
|
33459705ed | ||
|
|
f4ca259852 | ||
|
|
a49ae68f13 | ||
|
|
50bd67e59d | ||
|
|
549b4d4179 | ||
|
|
26ffad4f24 | ||
|
|
1a81ac88f3 | ||
|
|
c9fca97cae | ||
|
|
7d87b26047 | ||
|
|
87930a2df1 | ||
|
|
29ee04cf03 | ||
|
|
8892b1022d | ||
|
|
b97fe497cf | ||
|
|
a729d86637 | ||
|
|
e5bd73272b | ||
|
|
037b42454a | ||
|
|
5cf259f974 | ||
|
|
1486078c82 | ||
|
|
01a4eaec49 | ||
|
|
10086b543f | ||
|
|
68ce0e2bdd | ||
|
|
ae4ca98826 | ||
|
|
1d1631e64e | ||
|
|
5721d50b3a | ||
|
|
bd3a120587 | ||
|
|
3e2592804a | ||
|
|
93c0842dbb | ||
|
|
13e8834405 | ||
|
|
3ab2320e1b | ||
|
|
258fc2245a | ||
|
|
5dd9baf5ea | ||
|
|
481807f8db | ||
|
|
8a90e74af3 | ||
|
|
ea9595ff53 | ||
|
|
5d796139f6 | ||
|
|
b3f8a5eea6 | ||
|
|
ae549c8613 | ||
|
|
af8efc0882 | ||
|
|
63103cfa0d | ||
|
|
b0938e6334 | ||
|
|
4b0cc9d37f | ||
|
|
909cc3d4c0 | ||
|
|
1a8f88029b | ||
|
|
3a8049887d | ||
|
|
2ed7bb04d8 | ||
|
|
db87f3cdd4 | ||
|
|
2c0f3fd9e3 | ||
|
|
63b167c408 | ||
|
|
406dfad41f | ||
|
|
85574e7e80 | ||
|
|
336395fcfa | ||
|
|
d9e8b3ba73 | ||
|
|
e625408f55 | ||
|
|
d2a981a45f | ||
|
|
76f2dd3b36 | ||
|
|
03dac9d6e8 | ||
|
|
eb93d49802 | ||
|
|
3f21bcf1a7 | ||
|
|
d708029325 | ||
|
|
485b8d7a70 | ||
|
|
c688932525 | ||
|
|
77d159838f | ||
|
|
c733c090f6 | ||
|
|
2a07c2818c | ||
|
|
52cbfd2708 | ||
|
|
a551c38171 | ||
|
|
0f42831afa | ||
|
|
5c870329a3 | ||
|
|
a236fdbc97 | ||
|
|
a2e9cef145 | ||
|
|
7d72502769 | ||
|
|
12a91e5293 | ||
|
|
d093b76bef | ||
|
|
120b9ddfac | ||
|
|
281e61c297 | ||
|
|
8ac6bfcc3b | ||
|
|
cdd448800e | ||
|
|
e977a29b0b | ||
|
|
2d8fa2662e | ||
|
|
b96c8fa718 | ||
|
|
c6a88e7dd7 | ||
|
|
087a83a27e | ||
|
|
6a241d760a |
+1
-1
@@ -1,7 +1,7 @@
|
||||
# editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
[*.{js,cjs,mjs,jsx,ts,cts,mts,tsx,json,jsonc}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
|
||||
@@ -23,8 +23,6 @@ targets:
|
||||
path: ./packages/appium
|
||||
- type: npm
|
||||
path: ./packages/base-driver
|
||||
- type: npm
|
||||
path: ./packages/eslint-config-appium-ts
|
||||
- type: npm
|
||||
path: ./packages/fake-driver
|
||||
- type: npm
|
||||
|
||||
@@ -33,8 +33,44 @@ runs:
|
||||
run: |
|
||||
# Replace npm with sfw npm in the install command
|
||||
install_cmd="${{ inputs.install-command }}"
|
||||
install_cmd="${install_cmd//npm /sfw npm }"
|
||||
$install_cmd
|
||||
sfw_install_cmd="${install_cmd//npm /sfw npm }"
|
||||
|
||||
# Socket Firewall can fail to reach its own backend (a transient
|
||||
# network error unrelated to the actual npm install) and, in that
|
||||
# case, either exit non-zero or exit 0 without having actually
|
||||
# completed the install, leaving node_modules incomplete. We only
|
||||
# want to fall back to a plain (non-firewalled) install when we can
|
||||
# positively attribute the failure to *that* Socket Firewall error —
|
||||
# a real npm/package problem (bad version, checksum mismatch, failing
|
||||
# postinstall script, etc.) must fail the job as-is, not be silently
|
||||
# retried and potentially masked.
|
||||
sfw_log="$(mktemp)"
|
||||
set +e
|
||||
$sfw_install_cmd 2>&1 | tee "$sfw_log"
|
||||
sfw_exit="${PIPESTATUS[0]}"
|
||||
set -e
|
||||
|
||||
sfw_infra_failure=0
|
||||
if grep -qi 'Socket Firewall encountered an unexpected error' "$sfw_log"; then
|
||||
sfw_infra_failure=1
|
||||
fi
|
||||
|
||||
install_incomplete=0
|
||||
if [ "$sfw_exit" -eq 0 ]; then
|
||||
npm ls --depth=0 >/dev/null 2>&1 || install_incomplete=1
|
||||
fi
|
||||
rm -f "$sfw_log"
|
||||
|
||||
if [ "$sfw_infra_failure" -eq 1 ] && { [ "$sfw_exit" -ne 0 ] || [ "$install_incomplete" -eq 1 ]; }; then
|
||||
echo "::warning::Socket Firewall failed to reach its backend; retrying with a plain npm install"
|
||||
$install_cmd
|
||||
elif [ "$sfw_exit" -ne 0 ]; then
|
||||
echo "::error::npm install failed"
|
||||
exit "$sfw_exit"
|
||||
elif [ "$install_incomplete" -eq 1 ]; then
|
||||
echo "::error::npm install appears incomplete and is not attributable to a Socket Firewall failure"
|
||||
exit 1
|
||||
fi
|
||||
- name: Build packages
|
||||
if: ${{ inputs.run-build == 'true' }}
|
||||
shell: bash
|
||||
|
||||
@@ -68,11 +68,6 @@ labels:
|
||||
matcher:
|
||||
files: ['packages/driver-test-support/**']
|
||||
|
||||
- label: '@appium/eslint-config-appium-ts'
|
||||
sync: true
|
||||
matcher:
|
||||
files: ['packages/eslint-config-appium-ts/**']
|
||||
|
||||
- label: '@appium/oxc-config'
|
||||
sync: true
|
||||
matcher:
|
||||
|
||||
@@ -3,6 +3,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- appium4
|
||||
# this list of paths could potentially impact the build
|
||||
paths:
|
||||
- '**/tsconfig*.json'
|
||||
@@ -23,6 +24,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- appium4
|
||||
paths:
|
||||
- '**/tsconfig*.json'
|
||||
- 'packages/base-driver/test-fixtures/**/*'
|
||||
@@ -197,3 +199,5 @@ jobs:
|
||||
run: npm run lint:ci
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
- name: Verify changelog generation
|
||||
run: npm run verify:changelog-generation
|
||||
|
||||
@@ -10,13 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
prepare_matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
versions: ${{ steps.generate-matrix.outputs.lts }}
|
||||
steps:
|
||||
- name: Select all current LTS versions of Node.js
|
||||
id: generate-matrix
|
||||
uses: msimerson/node-lts-versions@v2.0.2
|
||||
uses: appium/appium-workflows/.github/workflows/node-lts-matrix.yml@main
|
||||
|
||||
check:
|
||||
needs:
|
||||
|
||||
@@ -8,6 +8,21 @@ on:
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
recover_missing_only:
|
||||
description: 'Recovery mode: skip version bump, publish only packages missing on npm at their current package.json version'
|
||||
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
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -18,7 +33,11 @@ permissions:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/master' && contains(fromJSON('["mykola-mokhnach", "jlipps", "eglitise", "KazuCocoa"]'), github.actor)
|
||||
# keep this branch name in sync with env.BETA_BRANCH below - the `env` context isn't available
|
||||
# to jobs.<job_id>.if (GitHub Actions resolves it before job-level env is set up)
|
||||
if: (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/appium4') && contains(fromJSON('["mykola-mokhnach", "jlipps", "eglitise", "KazuCocoa"]'), github.actor)
|
||||
env:
|
||||
BETA_BRANCH: appium4
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -43,15 +62,158 @@ jobs:
|
||||
# for lerna createRelease
|
||||
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
|
||||
|
||||
# On the beta branch ($BETA_BRANCH), every release is a beta prerelease anchored at its
|
||||
# target major (see packages/semantic-release-config's "Next-major beta branches" docs
|
||||
# for the equivalent semantic-release behavior this mirrors).
|
||||
#
|
||||
# allow_legacy_peer_deps: independent next-major beta.0 bumps can briefly outrun a
|
||||
# dependent's committed peerDependencies range, aborting lerna's internal lockfile
|
||||
# refresh with ERESOLVE. Opt-in only - it masks a real (if temporary) peer mismatch.
|
||||
LEGACY_PEER_DEPS_FLAGS=""
|
||||
if [ "$ALLOW_LEGACY_PEER_DEPS" = "true" ]; then
|
||||
LEGACY_PEER_DEPS_FLAGS="--npm-client-args=--legacy-peer-deps"
|
||||
fi
|
||||
|
||||
BETA_FLAGS=""
|
||||
DIST_TAG_FLAGS=""
|
||||
if [ "${{ github.ref_name }}" = "$BETA_BRANCH" ]; then
|
||||
BETA_FLAGS="--conventional-prerelease --preid beta $LEGACY_PEER_DEPS_FLAGS"
|
||||
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() {
|
||||
for pkg_json in packages/*/package.json; do
|
||||
if [ ! -f "$pkg_json" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$(jq -r '.private // false' "$pkg_json")" = "true" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
pkg_name="$(jq -r '.name // empty' "$pkg_json")"
|
||||
pkg_version="$(jq -r '.version // empty' "$pkg_json")"
|
||||
if [ -z "$pkg_name" ] || [ -z "$pkg_version" ]; then
|
||||
echo "Skipping malformed package manifest: $pkg_json" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
pkg_spec="${pkg_name}@${pkg_version}"
|
||||
if ! npm view "$pkg_spec" version >/dev/null 2>&1; then
|
||||
echo "$pkg_spec"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Resolves the workspace directory of a package given its npm name.
|
||||
package_dir_for() {
|
||||
local target="$1"
|
||||
for pkg_json in packages/*/package.json; do
|
||||
if [ ! -f "$pkg_json" ]; then
|
||||
continue
|
||||
fi
|
||||
if [ "$(jq -r '.name // empty' "$pkg_json")" = "$target" ]; then
|
||||
dirname "$pkg_json"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Publishes each "<pkg_name>@<pkg_version>" package spec passed as an argument.
|
||||
# `lerna publish` has no per-package scope filter, so this runs `npm publish`
|
||||
# directly from each package's directory instead. Returns non-zero if any
|
||||
# individual publish failed; npm publish's own exit code is taken as authoritative,
|
||||
# no npm registry re-scan is done to double-check it.
|
||||
publish_missing_packages() {
|
||||
echo "Publishing missing packages one-by-one"
|
||||
local overall_status=0
|
||||
for pkg_spec in "$@"; do
|
||||
pkg_name="${pkg_spec%@*}"
|
||||
pkg_dir="$(package_dir_for "$pkg_name")"
|
||||
if [ -z "$pkg_dir" ]; then
|
||||
echo "Could not resolve workspace directory for ${pkg_name}"
|
||||
overall_status=1
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Publishing ${pkg_spec}"
|
||||
set +e
|
||||
(cd "$pkg_dir" && npm publish $DIST_TAG_FLAGS)
|
||||
publish_exit_code=$?
|
||||
set -e
|
||||
if [ $publish_exit_code -ne 0 ]; then
|
||||
echo "npm publish failed for ${pkg_spec} (exit code ${publish_exit_code})"
|
||||
overall_status=1
|
||||
fi
|
||||
done
|
||||
return $overall_status
|
||||
}
|
||||
|
||||
if [ "$RECOVER_MISSING_ONLY" = "true" ]; then
|
||||
echo "Recovery mode: scanning for packages missing on npm at their current package.json version"
|
||||
mapfile -t missing < <(find_missing_packages)
|
||||
|
||||
if [ ${#missing[@]} -eq 0 ]; then
|
||||
echo "No missing packages found; nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Missing package versions on npm:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "Dry run: not publishing"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! publish_missing_packages "${missing[@]}"; then
|
||||
echo "Recovery incomplete: one or more packages failed to publish"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All missing packages published successfully"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
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
|
||||
printf "n\n" | npx lerna version --exact --no-git-tag-version --no-push $BETA_FLAGS
|
||||
else
|
||||
echo "Running lerna publish for real"
|
||||
set +e
|
||||
release_output="$(npm run release -- --yes 2>&1)"
|
||||
release_output="$(npm run release -- --yes $BETA_FLAGS $DIST_TAG_FLAGS 2>&1)"
|
||||
release_exit_code=$?
|
||||
set -e
|
||||
printf '%s\n' "$release_output"
|
||||
@@ -76,55 +238,13 @@ jobs:
|
||||
fi
|
||||
|
||||
git checkout "$tagged_commit"
|
||||
missing=()
|
||||
for pkg_json in packages/*/package.json; do
|
||||
if [ ! -f "$pkg_json" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$(jq -r '.private // false' "$pkg_json")" = "true" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
pkg_name="$(jq -r '.name // empty' "$pkg_json")"
|
||||
pkg_version="$(jq -r '.version // empty' "$pkg_json")"
|
||||
if [ -z "$pkg_name" ] || [ -z "$pkg_version" ]; then
|
||||
echo "Skipping malformed package manifest: $pkg_json"
|
||||
continue
|
||||
fi
|
||||
|
||||
pkg_spec="${pkg_name}@${pkg_version}"
|
||||
if ! npm view "$pkg_spec" version >/dev/null 2>&1; then
|
||||
missing+=("$pkg_spec")
|
||||
fi
|
||||
done
|
||||
mapfile -t missing < <(find_missing_packages)
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo "Missing package versions on npm before scoped recovery:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
echo "Publishing missing packages one-by-one with scope"
|
||||
for pkg_spec in "${missing[@]}"; do
|
||||
pkg_name="${pkg_spec%@*}"
|
||||
echo "Publishing ${pkg_name}"
|
||||
set +e
|
||||
npx lerna publish from-package --yes --scope "$pkg_name"
|
||||
scoped_exit_code=$?
|
||||
set -e
|
||||
if [ $scoped_exit_code -ne 0 ]; then
|
||||
echo "Scoped publish exited non-zero for ${pkg_name}; will verify on npm"
|
||||
fi
|
||||
done
|
||||
|
||||
final_missing=()
|
||||
for pkg_spec in "${missing[@]}"; do
|
||||
if ! npm view "$pkg_spec" version >/dev/null 2>&1; then
|
||||
final_missing+=("$pkg_spec")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#final_missing[@]} -gt 0 ]; then
|
||||
echo "Recovery incomplete after scoped retries. Still missing:"
|
||||
printf ' - %s\n' "${final_missing[@]}"
|
||||
if ! publish_missing_packages "${missing[@]}"; then
|
||||
echo "Recovery incomplete: one or more packages failed to publish"
|
||||
exit $release_exit_code
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -15,7 +15,11 @@ permissions:
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/master' && contains(fromJSON('["mykola-mokhnach", "jlipps", "eglitise", "KazuCocoa"]'), github.actor)
|
||||
# keep this branch name in sync with env.BETA_BRANCH below - the `env` context isn't available
|
||||
# to jobs.<job_id>.if (GitHub Actions resolves it before job-level env is set up)
|
||||
if: (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/appium4') && contains(fromJSON('["mykola-mokhnach", "jlipps", "eglitise", "KazuCocoa"]'), github.actor)
|
||||
env:
|
||||
BETA_BRANCH: appium4
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -38,4 +42,9 @@ jobs:
|
||||
- name: Run Lerna version
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx lerna version --exact --yes
|
||||
run: |
|
||||
BETA_FLAGS=""
|
||||
if [ "${{ github.ref_name }}" = "$BETA_BRANCH" ]; then
|
||||
BETA_FLAGS="--conventional-prerelease --preid beta"
|
||||
fi
|
||||
npx lerna version --exact --yes $BETA_FLAGS
|
||||
|
||||
@@ -2,10 +2,12 @@ name: Publish docs
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ['Appium Server Release']
|
||||
types:
|
||||
- completed
|
||||
push:
|
||||
# Runs when any new Appium tag is pushed = there is a new release
|
||||
# Does not trigger for tags with a slash, e.g. 1.0/beta
|
||||
tags:
|
||||
- appium@*
|
||||
- '!appium@*beta*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -13,6 +15,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0 # ensure the docs branch is also fetched
|
||||
- run: |
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
@@ -24,9 +28,5 @@ jobs:
|
||||
run-build: 'true'
|
||||
- name: Install dependencies (Python)
|
||||
run: npm run install-docs-deps
|
||||
- name: Fetch gh-pages
|
||||
run: |
|
||||
git fetch origin gh-pages:gh-pages || true
|
||||
- name: Publish docs
|
||||
run: |
|
||||
npm run docs:publish
|
||||
run: npm run docs:publish
|
||||
|
||||
@@ -29,8 +29,10 @@ to driver/client documentation
|
||||
* [fake-plugin](packages/fake-plugin/CHANGELOG.md)
|
||||
* [logger](packages/logger/CHANGELOG.md)
|
||||
* [opencv](packages/opencv/CHANGELOG.md)
|
||||
* [oxc-config](packages/oxc-config/CHANGELOG.md)
|
||||
* [plugin-test-support](packages/plugin-test-support/CHANGELOG.md)
|
||||
* [schema](packages/schema/CHANGELOG.md)
|
||||
* [semantic-release-config](packages/semantic-release-config/CHANGELOG.md)
|
||||
* [strongbox](packages/strongbox/CHANGELOG.md)
|
||||
* [support](packages/support/CHANGELOG.md)
|
||||
* [tsconfig](packages/tsconfig/CHANGELOG.md)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import appiumConfig from '@appium/eslint-config-appium-ts';
|
||||
import {defineConfig, globalIgnores} from 'eslint/config';
|
||||
|
||||
// Create a modified config subset for test support files:
|
||||
// Extract the test file related configs (Mocha plugin and custom rules),
|
||||
// then override their 'files' property (match by name — resilient to new config entries).
|
||||
const testFileConfigItems = appiumConfig.filter(
|
||||
(item) => typeof item?.name === 'string' && item.name.startsWith('Test Files'),
|
||||
);
|
||||
const testSupportFiles = ['packages/driver-test-support/lib/**', 'packages/plugin-test-support/lib/**'];
|
||||
const testSupportConfig = testFileConfigItems.map((item) => ({...item, files: testSupportFiles}));
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
name: 'Base Config',
|
||||
extends: [appiumConfig],
|
||||
},
|
||||
{
|
||||
name: 'Test Support',
|
||||
extends: [testSupportConfig],
|
||||
},
|
||||
{
|
||||
name: 'Consistent type-only imports (all packages)',
|
||||
files: ['**/*.{ts,tsx,mtsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{fixStyle: 'separate-type-imports', prefer: 'type-imports'},
|
||||
],
|
||||
},
|
||||
},
|
||||
globalIgnores([
|
||||
'**/build-fixtures/**',
|
||||
'packages/appium/docs/**/assets/**',
|
||||
'packages/appium/docs/**/js/**',
|
||||
'packages/appium/sample-code/**',
|
||||
]),
|
||||
]);
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"version": {
|
||||
"conventionalCommits": true,
|
||||
"createRelease": "github",
|
||||
"allowBranch": ["master", "appium3"],
|
||||
"allowBranch": ["master", "appium4"],
|
||||
"exact": true
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+1878
-5954
File diff suppressed because it is too large
Load Diff
+13
-32
@@ -75,18 +75,14 @@
|
||||
"test:types": "lerna run test:types",
|
||||
"test:unit": "lerna run test",
|
||||
"upload": "gulp github-upload",
|
||||
"verify:changelog-generation": "node ./scripts/verify-changelog-generation.mjs",
|
||||
"zip": "zip -qr ./appium.zip ./packages/appium",
|
||||
"zip-and-upload": "run-s zip upload"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@appium/oxc-config": "1.0.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@tsconfig/node20": "20.1.9",
|
||||
"@tsconfig/node22": "22.0.0",
|
||||
"@types/async-lock": "1.4.2",
|
||||
"@types/bluebird": "3.5.42",
|
||||
"@types/chai": "5.2.3",
|
||||
"@types/chai-as-promised": "8.0.2",
|
||||
"@types/content-disposition": "0.5.9",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/finalhandler": "1.2.4",
|
||||
@@ -94,52 +90,37 @@
|
||||
"@types/json-schema": "7.0.15",
|
||||
"@types/klaw": "3.0.7",
|
||||
"@types/lockfile": "1.0.4",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/method-override": "3.0.0",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/morgan": "1.9.10",
|
||||
"@types/mv": "2.1.4",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/plist": "3.0.5",
|
||||
"@types/picomatch": "4.0.3",
|
||||
"@types/pluralize": "0.0.33",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/semver": "7.8.0",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"@types/shell-quote": "1.7.5",
|
||||
"@types/sinon": "22.0.0",
|
||||
"@types/sinon-chai": "4.0.0",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/xmldom": "0.1.34",
|
||||
"@types/yargs": "17.0.35",
|
||||
"asyncbox": "6.3.5",
|
||||
"chai": "6.2.2",
|
||||
"chai-as-promised": "8.0.2",
|
||||
"conventional-changelog-conventionalcommits": "7.0.2",
|
||||
"asyncbox": "6.4.2",
|
||||
"conventional-changelog-conventionalcommits": "9.3.1",
|
||||
"cpy-cli": "7.0.0",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-mocha": "11.3.0",
|
||||
"eslint-plugin-n": "18.2.2",
|
||||
"eslint-plugin-promise": "7.3.0",
|
||||
"finalhandler": "2.1.1",
|
||||
"json-schema-to-typescript": "15.0.4",
|
||||
"lerna": "9.0.7",
|
||||
"lerna": "10.0.1",
|
||||
"mjpeg-consumer": "2.0.0",
|
||||
"mjpeg-server": "0.3.1",
|
||||
"npm-run-all2": "9.0.2",
|
||||
"rewiremock": "3.14.6",
|
||||
"npm-run-all2": "9.0.3",
|
||||
"rimraf": "6.1.3",
|
||||
"serve-static": "2.2.1",
|
||||
"sinon": "22.1.0",
|
||||
"sinon-chai": "4.0.1",
|
||||
"teen_process": "4.1.9",
|
||||
"teen_process": "4.2.1",
|
||||
"tsd": "0.33.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"webdriverio": "9.29.1",
|
||||
"ws": "8.21.1"
|
||||
"webdriverio": "9.31.2",
|
||||
"ws": "8.21.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@sidvind/better-ajv-errors": {
|
||||
@@ -151,7 +132,7 @@
|
||||
"http-cache-semantics": "4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0",
|
||||
"npm": ">=10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,118 @@
|
||||
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.0](https://github.com/appium/appium/compare/appium@3.7.0...appium@4.0.0-beta.0) (2026-09-19)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **base-driver:** align timeouts endpoints closer to W3C standard (#22776)
|
||||
* users relying on the implicit debug default for console/file logs will now see info-level output unless they explicitly pass --log-level debug.
|
||||
* **support:** Any script or tool that consumes Appium CLI output (driver/plugin list/install/update, doctor, etc.) and relies on the previous stream layout will need to be updated. Previously, non-JSON human-readable output (info/success/warning messages) was written to STDERR, with STDOUT reserved almost exclusively for --json output. Now that output goes to STDOUT, and only actual errors go to STDERR.
|
||||
* **support:** Scripts that discarded STDOUT (1>/dev/null) to suppress "chatter" while relying on STDERR for status text will now see that text on STDOUT instead.
|
||||
* **support:** Scripts that captured STDERR to detect/log informational or success messages will no longer see them there — only genuine errors will appear on STDERR. --json output and error behavior are unaffected: --json continues to write clean JSON to STDOUT only, and errors continue to go to STDERR.
|
||||
* **support:** @appium/logger's Log class changes its default output stream from STDERR to STDOUT for all levels except error, and adds an errorStream/stderrLevel pair of properties. Any code that set .stream on a Log instance expecting it to capture all levels (including error) will now need to also set .errorStream to the same target.
|
||||
* **base-driver:** @appium/base-driver's default export (BaseDriver) and @appium/base-driver's basedriver/helpers.js default export are removed; use the named exports instead. The appium/driver.js and driver.d.ts re-exports of the default are removed accordingly.
|
||||
* **base-driver:** BaseDriver.reset() is removed; it was unused, not part of any protocol route or the Driver interface, and had no callers in this monorepo or any driver package that was checked.
|
||||
* **base-driver:** BaseDriver's constructor is removed in favor of inherited DriverCore construction; opts is no longer a required constructor argument (it now defaults to {}, matching DriverCore).
|
||||
* **support:** @appium/support's fs.glob option shape changed from the glob package's GlobOptions to a smaller, @appium/support-owned GlobOptions interface. Supported fields: cwd, withFileTypes, absolute, lazy. Options like nodir/ignore/nocase are no longer supported directly — callers needing directory filtering can pass withFileTypes: true and filter on .isFile()/.isDirectory(); callers needing path exclusion can filter the returned array by pattern.
|
||||
* **appium:** The --allow-cors CLI flag and allow-cors config file property have been removed. Use --allow-insecure=*:cors (or --relaxed-security, optionally overridden with --deny-insecure=*:cors) instead.
|
||||
* **base-driver:** ISessionHandler.createSession (@appium/types) and LegacyCreateSessionArgs — overload and type deleted, single-argument signature only.
|
||||
* **base-driver:** BaseDriver.createSession and AppiumDriver.createSession — collapsed to the single-argument mplementation; capability validation now checks the one argument directly instead of scanning three positions for the first W3C-shaped value.
|
||||
* **base-driver:** FakeDriver.createSession (and the base-driver e2e test double) — overrides updated to match.
|
||||
* **base-driver:** RelaxedCapsPlugin.createSession — this plugin existed specifically to normalize capabilities across the (now-gone) three positions; its hook is simplified to a single caps argument.
|
||||
* **base-driver:** POST /session wire route (w3c.ts) — payloadParams.optional no longer sends capabilities three times into the command args.
|
||||
* **base-driver:** BaseDriver.getSession, ISessionHandler.getSession, and the GET /session/:sessionId JSONWP route are gone. Use getAppiumSessionCapabilities to retrieve session capabilities and EventCommands.getLogEvents to retrieve event history instead.
|
||||
* **base-driver:** The appium:eventTimings capability is removed; it only ever controlled whether getSession's response was decorated with event history, which getLogEvents already exposes unconditionally.
|
||||
* **support:** move npm-related helpers into appium package (#22721)
|
||||
* **logger:** @appium/logger and @appium/support no longer export a default log — import the named log export instead.
|
||||
* **logger:** AppiumLogger#errorAndThrow has been removed; use errorWithException instead.
|
||||
* **logger:** Logger#enableProgress, disableProgress, progressEnabled, enableUnicode, disableUnicode have been removed; they were already permanently disabled no-ops.
|
||||
* **base-plugin:** Plugin#logger has been removed; use Plugin#log instead.
|
||||
* **base-plugin:** @appium/base-plugin no longer exports BasePlugin as the default export; use the named BasePlugin export.
|
||||
* Core.driverData, DriverData, and the driverData parameter on ISessionHandler.createSession/deleteSession are gone. Any driver overriding get driverData() or relying on the umbrella driver passing sibling-session data into createSession/deleteSession needs to migrate off this mechanism.
|
||||
* FakeDriver no longer supports the uniqueApp capability.
|
||||
* **support:** npm.installPackage now takes a hasAppiumDependency boolean computed by the caller instead of importing the check itself, avoiding a support -> appium dependency.
|
||||
* **support:** The env export is removed from @appium/support.
|
||||
* the minimum supported Node.js engine is set to ^22.22.2 || ^24.15.0 || >=26.0.0
|
||||
* **base-driver:** @appium/base-driver no longer exports statusCodes or getSummaryByCode (the jsonwp-status module was removed).
|
||||
* **base-driver:** errorFromMJSONWPStatusCode (aliased as errorFromCode) has been removed; use errorFromW3CJsonCode for W3C error-signature mapping.
|
||||
* **base-driver:** ProtocolError instances no longer have a jsonwpCode property.
|
||||
* **base-driver:** WebDriverProxy (formerly JWProxy) no longer proxies to or interprets responses from downstream servers speaking the legacy MJSONWP protocol; the ProtocolConverter class, COMMAND_URLS_CONFLICTS, and the downstreamProtocol getter/setter have been removed. Downstream automation servers must speak W3C WebDriver.
|
||||
* **base-driver:** Responses no longer duplicate element references under the legacy ELEMENT key; only the W3C element-6066-11e4-a52e-4f735466cecf key is present. The MJSONWP_ELEMENT_KEY constant has been removed.
|
||||
* **base-driver:** determineProtocol has been removed, and drivers can no longer produce the legacy {sessionId, status, value} response shape; all responses are now W3C-shaped regardless of how the client formatted its createSession capabilities.
|
||||
* **base-driver:** DriverCore#setProtocolMJSONWP() has been removed.
|
||||
* **base-driver:** The deprecated JWProxy export has been removed; use WebDriverProxy instead. Its source also moved from lib/jsonwp-proxy/proxy.js to lib/wd-proxy/proxy.js.
|
||||
* **base-driver:** DriverCore#isMjsonwpProtocol() has been removed.
|
||||
* **support:** The following @appium/support exports have been removed: process (getProcessIds, killProcess), mkdirp, imageUtil (requireSharp, cropBase64Image), mjpeg (MJpegStream), net's FTP upload
|
||||
support (FtpUploadOptions, NotHttpUploadOptions; uploadFile only supports http/https now), util's uuidV1/uuidV3/uuidV5/localIp/cancellableDelay/multiResolve, and fs's readPackageJsonFrom/findRoot/ F_OK/R_OK/W_OK/X_OK.
|
||||
* **appium:** The --nodeconfig server argument and the server.nodeconfig config file property no longer exist. Users who need to register with a Selenium Grid should use the Grid 4 relay feature instead (see the Grid guide).
|
||||
* **base-driver:** TLS-enabled Appium servers no longer negotiate SPDY/HTTP2 (spdy/2, spdy/3, spdy/3.1, h2) via ALPN; connections are now served as plain HTTPS (HTTP/1.1) using Node's native https module. Clients relying on SPDY or HTTP/2 multiplexing against Appium's TLS listener must switch to HTTP/1.1.
|
||||
* **appium:** `npm install -g appium --drivers=... --plugins=...` no longer installs extensions automatically. Use `appium driver/plugin install` or add extensions to package.json instead.
|
||||
* require('@appium/support') no longer works; consumers must import it instead.
|
||||
* require('@appium/base-driver') no longer works; drivers extending BaseDriver via require must switch to import.
|
||||
* require('@appium/base-plugin') no longer works; plugins extending BasePlugin via require must switch to import.
|
||||
* require('appium') no longer works; plugins extending the package via require must switch to import.
|
||||
* require('@appium/fake-plugin') no longer works; consumers must import it instead.
|
||||
* require('@appium/fake-driver') no longer works; consumers must import it instead.
|
||||
* require('@appium/types') no longer works; consumers must import it instead.
|
||||
* require('@appium/logger') no longer works; consumers must import it instead.
|
||||
* require('@appium/schema') no longer works; consumers must import it instead.
|
||||
|
||||
### Features
|
||||
|
||||
* **appium:** add --app-url-rules server arg to restrict remote app URLs ([#22753](https://github.com/appium/appium/issues/22753)) ([0b104f6](https://github.com/appium/appium/commit/0b104f6bf9b7180b111df40fd6a15b26d649eabd))
|
||||
* **appium:** remove postinstall autoinstall-extensions script ([#22675](https://github.com/appium/appium/issues/22675)) ([cd0d8f1](https://github.com/appium/appium/commit/cd0d8f14e9d5906ecd1b438038a7f67f74f742fc))
|
||||
* **appium:** remove Selenium Grid 3 support ([#22677](https://github.com/appium/appium/issues/22677)) ([8fcdcb3](https://github.com/appium/appium/commit/8fcdcb30b07c1ee582256fbef3538d6a3ceea5f2))
|
||||
* **appium:** replace --allow-cors flag with --allow-insecure=*:cors ([#22731](https://github.com/appium/appium/issues/22731)) ([926a0a5](https://github.com/appium/appium/commit/926a0a594935a6cf2e7375eb174eeb15af964f12))
|
||||
* **appium:** validate extension manifests and extensions.yaml via ajv ([#22732](https://github.com/appium/appium/issues/22732)) ([8840f9f](https://github.com/appium/appium/commit/8840f9f9322b63bc8940c390b8230f1e5306fa62))
|
||||
* backport diff from master ([#22749](https://github.com/appium/appium/issues/22749)) ([6323df1](https://github.com/appium/appium/commit/6323df1d7354f85cc631508f5cb6a3e35270d4bd))
|
||||
* **base-driver:** add definitions for all WebDriver BiDi commands ([#22770](https://github.com/appium/appium/issues/22770)) ([ac01aa8](https://github.com/appium/appium/commit/ac01aa83ad73982eea48f856dfc0249b4a26e395))
|
||||
* **base-driver:** align timeouts endpoints closer to W3C standard ([#22776](https://github.com/appium/appium/issues/22776)) ([93115d1](https://github.com/appium/appium/commit/93115d195773b011306ffb2e6d7d6cc935df0148))
|
||||
* **base-driver:** remove legacy (M)JSONWP protocol support ([#22681](https://github.com/appium/appium/issues/22681)) ([0b83e77](https://github.com/appium/appium/commit/0b83e77e7a1c24a885d9895a99db30281cf4acea))
|
||||
* **base-driver:** replace spdy with native https transport ([#22676](https://github.com/appium/appium/issues/22676)) ([ec7de47](https://github.com/appium/appium/commit/ec7de4768fdd8dde9d28db6fc2dfd63be45fd4c6))
|
||||
* bump minimum supported Node.js engine to ^22.22.2 || ^24.15.0 || >=26.0.0 ([#22685](https://github.com/appium/appium/issues/22685)) ([9f4a11e](https://github.com/appium/appium/commit/9f4a11e7190290986d01743557e90e6e44f87638))
|
||||
* Change the default log level to `info` ([#22756](https://github.com/appium/appium/issues/22756)) ([5e644a3](https://github.com/appium/appium/commit/5e644a392fbf11e2ca985408853d7f51aa85be17))
|
||||
* convert remaining monorepo packages to ESM-only ([#22674](https://github.com/appium/appium/issues/22674)) ([3516e50](https://github.com/appium/appium/commit/3516e50ce6d022f4c0dc539071c593bf2983325f))
|
||||
* **support:** move env/APPIUM_HOME resolution into appium package ([#22708](https://github.com/appium/appium/issues/22708)) ([8db8e57](https://github.com/appium/appium/commit/8db8e5748e26a7150cacbea09ba3ec52c5ce3fc0))
|
||||
* **support:** remove deprecated APIs ([#22678](https://github.com/appium/appium/issues/22678)) ([e8ee716](https://github.com/appium/appium/commit/e8ee71685b4c21116e61c88fd8d2f0e39fac2edb))
|
||||
* **types:** check execute method params against the command signature ([#22754](https://github.com/appium/appium/issues/22754)) ([b25fc20](https://github.com/appium/appium/commit/b25fc20c51a191c33695c1c71fbf129de834b52d))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address code review cleanups (plist, env memoize, test reuse) ([#22740](https://github.com/appium/appium/issues/22740)) ([0df8834](https://github.com/appium/appium/commit/0df883463812d57d44a0d6d80e9b88f124361934))
|
||||
* Address several TODOs ([#22762](https://github.com/appium/appium/issues/22762)) ([d6ef7e3](https://github.com/appium/appium/commit/d6ef7e3f1240f161df622c850aea06637f9d1078))
|
||||
* adjust deprecated JSONWP/MJSONWP routes ([#22763](https://github.com/appium/appium/issues/22763)) ([b129cbb](https://github.com/appium/appium/commit/b129cbbcd55656ae7f33b80b4b52719eb5179509))
|
||||
* **appium:** lock the extension manifest while driver/plugin commands run ([#22767](https://github.com/appium/appium/issues/22767)) ([a73644f](https://github.com/appium/appium/commit/a73644fb4f4e7b0363117ac1262d7018094b42ff))
|
||||
* **base-driver:** clean up aborted session creation ([#22733](https://github.com/appium/appium/issues/22733)) ([33d866e](https://github.com/appium/appium/commit/33d866e57506e1546aa4870dde75f7209be107bc))
|
||||
* declare dependencies that were only resolving via hoisting ([#22758](https://github.com/appium/appium/issues/22758)) ([bcf1c57](https://github.com/appium/appium/commit/bcf1c579997b8dbb8292b1a77583aeef40b11ba4))
|
||||
* **support:** route non-error CLI console output to stdout ([#22742](https://github.com/appium/appium/issues/22742)) ([4eb4d7f](https://github.com/appium/appium/commit/4eb4d7f317a685d681b6cf4e25c15b617a8cc7df))
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **base-driver:** remove deprecated getSession command ([#22725](https://github.com/appium/appium/issues/22725)) ([f71d980](https://github.com/appium/appium/commit/f71d980779d23e9a19d33285646fad128eae4917))
|
||||
* **base-driver:** remove deprecated multi-argument createSession overload ([#22730](https://github.com/appium/appium/issues/22730)) ([db72249](https://github.com/appium/appium/commit/db722498739ae7fe1deef1b98e02d04b455a4f70))
|
||||
* **base-driver:** replace command mixin with explicit named exports ([#22741](https://github.com/appium/appium/issues/22741)) ([506bfee](https://github.com/appium/appium/commit/506bfeeb9aa15c0dd1f481868d347cb9b0d37f86))
|
||||
* **base-plugin:** remove deprecated legacy logger and default export ([#22718](https://github.com/appium/appium/issues/22718)) ([62db5f0](https://github.com/appium/appium/commit/62db5f0b20c496b495ce27d490453861ddefb259))
|
||||
* **logger:** remove default exports and dead logging APIs ([#22719](https://github.com/appium/appium/issues/22719)) ([fe24f52](https://github.com/appium/appium/commit/fe24f527a4412ed86c7b5e5afe0c6e26dc569e6b))
|
||||
* remove deprecated driverData mechanism ([#22716](https://github.com/appium/appium/issues/22716)) ([ef8a8e1](https://github.com/appium/appium/commit/ef8a8e1934bfdc9699c2b9a7eca3e2201b4b9f93))
|
||||
* **support:** move npm-related helpers into appium package ([#22721](https://github.com/appium/appium/issues/22721)) ([d82d496](https://github.com/appium/appium/commit/d82d496f9e80d6628abface3b623601a6f9791f9))
|
||||
* **support:** replace glob package with native node:fs glob ([#22735](https://github.com/appium/appium/issues/22735)) ([add1615](https://github.com/appium/appium/commit/add161560701ac7fb2bc372ddefd9317146fbae8))
|
||||
|
||||
|
||||
## [3.7.0](https://github.com/appium/appium/compare/appium@3.6.0...appium@3.7.0) (2026-08-24)
|
||||
|
||||
### Features
|
||||
|
||||
* **base-driver:** add httpServer.frontRouter for global extension middleware ([#22632](https://github.com/appium/appium/issues/22632)) ([f06226b](https://github.com/appium/appium/commit/f06226b7bb2fb7ee2f3a61833f4a1d7777d4ef21))
|
||||
* **storage-plugin:** Add route aliases ([#22558](https://github.com/appium/appium/issues/22558)) ([a551c38](https://github.com/appium/appium/commit/a551c3817156bbc7e06793227643a2d11c0c0259))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **appium:** load @appium/docutils dynamically in docs build script ([#22584](https://github.com/appium/appium/issues/22584)) ([2c0f3fd](https://github.com/appium/appium/commit/2c0f3fd9e319c3bc774bdd2a65148da11094c87c))
|
||||
* **appium:** parse --webhook as a URI instead of host:port ([#22635](https://github.com/appium/appium/issues/22635)) ([c35a12a](https://github.com/appium/appium/commit/c35a12a51d30fe311ef96ebe386cd9c5540e3927))
|
||||
* **support:** do not treat a sibling folder as a subpath of the root ([#22586](https://github.com/appium/appium/issues/22586)) ([3a80498](https://github.com/appium/appium/commit/3a8049887dc673a6658c3821a2882e0145c3aa86))
|
||||
|
||||
|
||||
## [3.6.0](https://github.com/appium/appium/compare/appium@3.5.2...appium@3.6.0) (2026-07-25)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ plugins:
|
||||
blog:
|
||||
enabled: true
|
||||
post_excerpt: required
|
||||
mike:
|
||||
canonical_version: 'latest'
|
||||
redirects:
|
||||
redirect_maps:
|
||||
'intro/requirements.md': 'quickstart/requirements.md'
|
||||
|
||||
@@ -17,3 +17,8 @@ authors:
|
||||
avatar: 'https://avatars.githubusercontent.com/u/4045041?v=4'
|
||||
slug: 'sai'
|
||||
url: 'https://saikrishna.tech'
|
||||
mykola:
|
||||
name: 'Mykola Mokhnach'
|
||||
description: 'Appium Core Maintainer'
|
||||
avatar: https://avatars.githubusercontent.com/u/7767781?v=4
|
||||
slug: 'mykola'
|
||||
|
||||
@@ -8,11 +8,13 @@ date: 2025-11-25
|
||||
# Announcing LambdaTest as Appium's Newest Development Partner
|
||||
|
||||
As part of our ongoing effort to recognize organizations that meaningfully support the Appium project through dedicated maintainer time, we are excited to welcome our newest Development Partner: LambdaTest!
|
||||
LambdaTest has long been an active participant in the automated testing ecosystem. Today, they are officially formalizing their commitment by investing people—dedicated engineers who will directly support Appium's development, maintenance, and ecosystem growth.
|
||||
This kind of hands-on contribution is crucial for ensuring Appium continues to evolve. We deeply value partners who empower their engineering teams to contribute code directly to open source rather than just sponsorship.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
LambdaTest has long been an active participant in the automated testing ecosystem. Today, they are officially formalizing their commitment by investing people—dedicated engineers who will directly support Appium's development, maintenance, and ecosystem growth.
|
||||
|
||||
This kind of hands-on contribution is crucial for ensuring Appium continues to evolve. We deeply value partners who empower their engineering teams to contribute code directly to open source rather than just sponsorship.
|
||||
|
||||
<div style="text-align:center;width:100%">
|
||||
<a href="https://lambdatest.com/?utm_source=appium.io&utm_medium=organic&utm_campaign=june_25&utm_term=sk&utm_content=webpage">
|
||||
<img src="/docs/en/latest/assets/images/sponsor-logo-lambdatest-dark.png#only-dark" width="300" />
|
||||
@@ -22,15 +24,19 @@ This kind of hands-on contribution is crucial for ensuring Appium continues to e
|
||||
|
||||
|
||||
LambdaTest engineers are already contributing actively across several key Appium repositories. Their current focus includes:
|
||||
**appium-mcp** – Supporting Appium's implementation of the Model Context Protocol (MCP) to drive forward modern AI-assisted capabilities.
|
||||
**appium-remote-xpc** – Contributing to the native macOS/XPC-based remote execution layer, essential for iOS automation.
|
||||
**appium-tun-tap** – Assisting in the development and maintenance of network virtualization and tunneling components for advanced testing scenarios.
|
||||
|
||||
* [`appium-mcp`](https://github.com/appium/appium-mcp) – Supporting Appium's implementation of the Model Context Protocol (MCP) to drive forward modern AI-assisted capabilities.
|
||||
* [`appium-ios-remotexpc`](https://github.com/appium/appium-ios-remotexpc) – Contributing to the native macOS/XPC-based remote execution layer, essential for iOS automation.
|
||||
* [`appium-ios-tuntap`](https://github.com/appium/appium-ios-tuntap) – Assisting in the development and maintenance of network virtualization and tunneling components for advanced testing scenarios.
|
||||
|
||||
These contributions strengthen critical infrastructure within the Appium ecosystem and demonstrate LambdaTest's commitment to deep technical involvement in the project's evolution.
|
||||
About LambdaTest
|
||||
|
||||
**About LambdaTest**
|
||||
|
||||
LambdaTest’s platform already serves a massive community of Appium users. As a GenAI-powered Quality Engineering Platform, they provide a full-stack testing cloud with 10,000+ real mobile devices and 3,000+ browsers.
|
||||
|
||||
Beyond standard infrastructure, they are pushing the boundaries of test execution with AI-native test management and next-generation agents like KaneAI. By supporting Appium alongside Selenium, Playwright, and other major frameworks, LambdaTest helps teams orchestrate intelligent automation at cloud scale.
|
||||
|
||||
LambdaTest will continue contributing people, code, and development expertise to strengthen Appium's core functionality. Their commitment represents a significant investment into the future of the framework, and we're thrilled to have them collaborating with us at this deeper level.
|
||||
|
||||
The Appium project is proud to recognize LambdaTest as our newest official Development Partner. We can't wait to see what we'll build together!
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
authors:
|
||||
- mykola
|
||||
date: 2026-09-01
|
||||
---
|
||||
|
||||
# Announcing watchOS Simulator Support in the XCUITest Driver
|
||||
|
||||
We’re excited to announce that the XCUITest driver now supports automation of watchOS apps — a
|
||||
platform that’s been on Appium’s requested list for a long time.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
In order to automate watchOS, you'll need the following:
|
||||
|
||||
* XCUITest driver version 12.6.0 or later (WebDriverAgent 16.5.0 or later)
|
||||
* Xcode 15.4 or later
|
||||
* watchOS 10 or later
|
||||
|
||||
Important to note: ^^the driver currently only works with simulators — real devices are not
|
||||
compatible.^^
|
||||
|
||||
The good news: automating a watchOS app looks just like automating any other iOS/iPadOS app. The
|
||||
standard use-cases of finding and clicking on elements, retrieving the page source, using the
|
||||
Appium Inspector — all work as usual. The one exception here is standard W3C gesture/touch actions
|
||||
(tap/swipe via the Actions API), which are not supported.
|
||||
|
||||
On top of that, a few watchOS-specific extensions are available:
|
||||
|
||||
- `mobile: pressButton` — press the Digital Crown or Action button
|
||||
- `mobile: rotateDigitalCrown` — rotate the Digital Crown (since driver version 12.7.0)
|
||||
- `mobile: performHandGesture` — perform double-tap or wrist flick hand gestures (since driver
|
||||
version 12.7.0)
|
||||
|
||||
For full setup instructions, capabilities, and limitations, check out the
|
||||
[watchOS guide](https://appium.github.io/appium-xcuitest-driver/latest/guides/watchos/).
|
||||
|
||||
Happy testing!
|
||||
@@ -222,18 +222,13 @@ So you'll probably end up overriding `createSession`. You can do so by defining
|
||||
driver:
|
||||
|
||||
```js
|
||||
async createSession(jwpCaps, reqCaps, w3cCaps, otherDriverData) {
|
||||
const [sessionId, caps] = super.createSession(w3cCaps);
|
||||
async createSession(w3cCaps) {
|
||||
const [sessionId, caps] = await super.createSession(w3cCaps);
|
||||
// do your own stuff here
|
||||
return [sessionId, caps];
|
||||
}
|
||||
```
|
||||
|
||||
For legacy reasons, your function will receive old-style JSON Wire Protocol desired and required
|
||||
caps as the first two arguments. Given that the old protocol isn't supported anymore and clients
|
||||
have all been updated, you can instead only rely on the `w3cCaps` parameter. (For a discussion
|
||||
about what `otherDriverData` is about, see the section below on concurrent drivers).
|
||||
|
||||
You'll want to make sure to call `super.createSession` in order to get the session ID as well as
|
||||
the processed capabilities (note that capabilities are also set on `this.caps`; modifying `caps`
|
||||
locally here would have no effect other than changing what the user sees in the create session
|
||||
@@ -520,14 +515,13 @@ route, then the route will not be proxied and instead will be handled by your dr
|
||||
example, we are avoiding proxying all `POST` routes that have the `appium` prefix.
|
||||
|
||||
Next, we have to set up the proxying itself. The way to do this is to use a special class from
|
||||
Appium called `JWProxy`. (The name means "JSON Wire Proxy" and is related to a legacy
|
||||
implementation of the protocol). You'll want to create a `JWProxy` object using the details required to
|
||||
connect to the remote server:
|
||||
Appium called `WebDriverProxy`. You'll want to create a `WebDriverProxy` object using the details
|
||||
required to connect to the remote server:
|
||||
|
||||
```js
|
||||
// import {JWProxy} from 'appium/driver';
|
||||
// import {WebDriverProxy} from 'appium/driver';
|
||||
|
||||
const proxy = new JWProxy({
|
||||
const proxy = new WebDriverProxy({
|
||||
server: 'remote.server',
|
||||
port: 1234,
|
||||
base: '/',
|
||||
@@ -539,8 +533,8 @@ this.proxyCommand = proxy.command.bind(proxy);
|
||||
|
||||
Here we are creating a proxy object and assigning some of its methods to `this` under the names
|
||||
`proxyReqRes` and `proxyCommand`. This is required for Appium to use the proxy, so don't forget
|
||||
this step! The `JWProxy` has a variety of other options which you can check out in the source code,
|
||||
as well. (TODO: publish options as API docs and link here).
|
||||
this step! The `WebDriverProxy` has a variety of other options [which you can check out in the
|
||||
source code as well.](https://github.com/appium/appium/blob/master/packages/base-driver/lib/wd-proxy/proxy.ts)
|
||||
|
||||
Finally, we need a way to tell Appium when the proxy is active. For your driver it might always
|
||||
be active, or it might only be active when in a certain context. You can define the logic as an
|
||||
@@ -720,6 +714,12 @@ A couple notes about this system:
|
||||
mode!
|
||||
1. The `executeMethod` helper will reject with an error if a script name doesn't match one of the
|
||||
script names defined as a command in `executeMethodMap`, or if there are missing parameters.
|
||||
1. If your driver is written in TypeScript, declare the map `as const satisfies
|
||||
ExecuteMethodMap<MyDriver>` (with `ExecuteMethodMap` imported from `@appium/types`). The
|
||||
compiler then checks that each `command` names a method of your driver, and that the
|
||||
`required`/`optional` params are consistent with that method's signature: the method must not
|
||||
require a parameter which is listed as `optional` (or not listed at all), and must accept all
|
||||
of the listed parameters.
|
||||
|
||||
One of the nice things about the Execute Method strategy is that methods implemented in this way
|
||||
will be available via the classic or BiDi interfaces (since they will result in the same Appium
|
||||
@@ -794,44 +794,32 @@ that multiple simultaneous sessions don't use the same resources:
|
||||
|
||||
1. Have your users specify resource IDs via capabilities (`appium:driverPort` etc)
|
||||
1. Just always use free resources (find a new random port for each session)
|
||||
1. Have each driver express what resources it is using, then examine currently-used resources from
|
||||
other drivers when a new session begins.
|
||||
1. Have each running instance of your driver publish and subscribe to messages on a shared IPC
|
||||
channel, so sessions can coordinate directly with one another.
|
||||
|
||||
To support this third strategy, you can implement `get driverData` in your driver to return what
|
||||
sorts of resources your driver is currently using, for example:
|
||||
|
||||
```js
|
||||
get driverData() {
|
||||
return {specialPort: 1234, specialFile: /path/to/file}
|
||||
}
|
||||
```
|
||||
|
||||
Now, when a new session is started on your driver, the `driverData` response from any other
|
||||
simultaneously running drivers (of the same type) will also be included, as the last parameter of
|
||||
the `createSession` method:
|
||||
|
||||
```js
|
||||
async createSession(jwpCaps, reqCaps, w3cCaps, driverData)
|
||||
```
|
||||
|
||||
You can dig into this `driverData` array to see what resources other drivers are using to help
|
||||
determine which ones you want to use for this particular session.
|
||||
The per-session IPC channel described in [Send messages to plugins running on the same
|
||||
session](#send-messages-to-plugins-running-on-the-same-session) is isolated per session, so it
|
||||
can't be used for this: it doesn't let you talk to *other* sessions of your driver. Instead,
|
||||
construct and hold on to your own `AppiumIpc` instance (exported from `appium/driver.js`) at
|
||||
module scope in your driver's package, for example as a memoized singleton. Since Node.js caches
|
||||
modules, every instance of your driver loaded within the same Appium server process will share
|
||||
that one `AppiumIpc` object, and can use it to publish and subscribe to topics across sessions —
|
||||
the same way the per-session channel works, just not scoped to a single session. See
|
||||
[xcuitest-driver's `session-claim-handler.ts`](https://github.com/appium/appium-xcuitest-driver/blob/master/lib/session-claim-handler.ts)
|
||||
for a real example, which uses this technique to detect and resolve conflicting sessions targeting
|
||||
the same device UDID.
|
||||
|
||||
!!! warning
|
||||
|
||||
Be careful here, since `driverData` is only passed between sessions of a single running Appium
|
||||
server. There's nothing to stop a user from running multiple Appium servers and requesting your
|
||||
driver simultaneously on each of them. In this case, you won't be able to ensure independence
|
||||
of resources via `driverData`, so you might consider using file-based locking mechanisms or
|
||||
something similar.
|
||||
This only works for sessions running within the same Node.js process (i.e. the same Appium
|
||||
server). It cannot coordinate sessions running on different Appium server processes; for that,
|
||||
consider file-based locking or a similar out-of-process mechanism.
|
||||
|
||||
!!! warning
|
||||
|
||||
It's also important to note you will only receive `driverData` for other instances of *your*
|
||||
driver. So unrelated drivers also running may still be using some system resources. In general
|
||||
Appium doesn't provide any features for ensuring unrelated drivers don't interfere with one
|
||||
another, so it's up to the drivers to allow users to specify resource locations or addresses to
|
||||
avoid clashes.
|
||||
Appium also doesn't provide any features for ensuring unrelated drivers don't interfere with
|
||||
one another, so it's up to the drivers to allow users to specify resource locations or
|
||||
addresses to avoid clashes.
|
||||
|
||||
### Log events to the Appium event timeline
|
||||
|
||||
@@ -979,10 +967,15 @@ export type IpcMessage<T> = {
|
||||
#### IPC Notes
|
||||
|
||||
There are some important things to keep in mind when using Appium's IPC feature:
|
||||
- IPC is only available once `onIpcInit` has been called, at the end of the `createSession` flow.
|
||||
It cannot be used statically or in `createSession` hooks
|
||||
- In sum, IPC is only for use during a session (this is also to prevent drivers/plugins from
|
||||
accessing or reading data sent on IPC channels in other sessions.)
|
||||
- The `ipcSubscribe` method (and the IPC channel it subscribes to) is only available once
|
||||
`onIpcInit` has been called, at the end of the `createSession` flow. It cannot be used statically
|
||||
or in `createSession` hooks
|
||||
- The channel that `ipcSubscribe` gives you access to is scoped to the current session, and is
|
||||
shared only with the plugins active in that same session (this is also to prevent drivers/plugins
|
||||
from accessing or reading data sent on IPC channels in other sessions.) To coordinate across
|
||||
multiple concurrent sessions of your own driver instead, see [Make itself aware of resources
|
||||
other concurrent drivers are
|
||||
using](#make-itself-aware-of-resources-other-concurrent-drivers-are-using).
|
||||
- The default max size of an IPC message is 1MB. This can be configured by the server-admin by
|
||||
using the `--max-ipc-data-size` arg (value is a number in bytes).
|
||||
- If a message exceeds the configured size, any call to `publish` methods will throw, so be
|
||||
|
||||
@@ -257,7 +257,7 @@ throw new errors.NoSuchElementError();
|
||||
### Log messages to the Appium log
|
||||
|
||||
You can always use `console.log`, of course, but Appium provides a nice logger for you as
|
||||
`this.logger` (it has `.info`, `.debug`, `.log`, `.warn`, `.error` methods on it for differing log
|
||||
`this.log` (it has `.info`, `.debug`, `.warn`, `.error` methods on it for differing log
|
||||
levels). If you want to create an Appium logger outside of a plugin context (say in a script or
|
||||
helper file), you can always construct your own too:
|
||||
|
||||
@@ -477,9 +477,9 @@ 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)
|
||||
in a variety of ways). But, for example, you could add new Express middleware 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:
|
||||
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:
|
||||
|
||||
* `expressApp`: the Express app object
|
||||
* `httpServer`: the Node HTTP server object
|
||||
@@ -492,6 +492,25 @@ results you'll need to test! Warning: this should be considered an advanced feat
|
||||
knowledge of Express, as well as the care not to do anything that could affect the operation of
|
||||
other parts of the Appium server!
|
||||
|
||||
`updateServer` runs *after* Appium's own routes are registered, so `expressApp.use(...)` here
|
||||
won't see requests to paths Appium already owns — Express matches middleware and routes in
|
||||
registration order. For middleware that must see requests to those routes too (e.g. logging or
|
||||
auth), use `httpServer.frontRouter` instead: Appium mounts this
|
||||
[Router](https://expressjs.com/en/5x/api/router/) before any route is registered, so anything
|
||||
attached to it — even from inside `updateServer` — still runs ahead of route matching. Note this
|
||||
is still *after* Appium's own baseline middleware (CORS, WebSocket upgrade handling, body
|
||||
parsing, etc.), so it won't see requests Appium itself has already rejected, e.g. a CORS
|
||||
preflight failure or a body that failed to parse:
|
||||
|
||||
```js
|
||||
static async updateServer(expressApp, httpServer, cliArgs) {
|
||||
httpServer.frontRouter.use((req, res, next) => {
|
||||
console.log(`Incoming request: ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Handle unexpected session shutdown
|
||||
|
||||
When developing a plugin you may want to add some cleanup logic for when a session ends. You would
|
||||
|
||||
@@ -126,7 +126,7 @@ appium driver install --source=npm appium-flutter-integration-driver
|
||||
appium driver install --source=npm appium-lg-webos-driver
|
||||
```
|
||||
|
||||
### [Linux](https://github.com/fantonglang/appium-linux-driver)
|
||||
### [Linux (@fantonglang)](https://github.com/fantonglang/appium-linux-driver)
|
||||
|
||||
!!! warning
|
||||
|
||||
@@ -143,6 +143,16 @@ yarn install
|
||||
node ./
|
||||
```
|
||||
|
||||
### [Linux (@Itsmeaj)](https://github.com/Itsmeaj/appium)
|
||||
|
||||
* Target: Linux desktop applications on X11 and Wayland
|
||||
* Mode: Native
|
||||
* Supported by: `@Itsmeaj`
|
||||
|
||||
```sh title="Install This Driver"
|
||||
appium driver install --source=npm @itsmeaj/appium-linux-driver
|
||||
```
|
||||
|
||||
### [NovaWindows](https://github.com/AutomateThePlanet/appium-novawindows-driver)
|
||||
|
||||
!!! info
|
||||
|
||||
@@ -6,14 +6,12 @@ title: Retrieving Event Timings
|
||||
---
|
||||
|
||||
Appium comes with the ability to retrieve timing information about startup
|
||||
information and command length. This is an advanced feature that is controlled
|
||||
by the use of the `appium:eventTimings` capability (set it to `true` to log event
|
||||
timings).
|
||||
information and command length. This is an advanced feature; event and
|
||||
command timings are collected automatically for the duration of a session.
|
||||
|
||||
With this capability turned on, the `POST /session/:id/appium/events` response (i.e.,
|
||||
the response to `driver.logs.events` or similar, depending on client) will be
|
||||
decorated with an `events` property. This is the structure of that `events`
|
||||
property:
|
||||
The `POST /session/:id/appium/events` response (i.e., the response to
|
||||
`driver.logs.events` or similar, depending on client) has the following
|
||||
structure:
|
||||
|
||||
```
|
||||
{
|
||||
|
||||
@@ -117,62 +117,3 @@ hub, you'll be able to send all your Appium traffic via the single hub endpoint
|
||||
|
||||
And of course, you're able to link up Appium servers and nodes running on different machines in
|
||||
your network to form a larger grid.
|
||||
|
||||
## Using Selenium Grid 3
|
||||
|
||||
It is possible to register your Appium server with a local [Selenium Grid 3](https://www.selenium.dev/documentation/legacy/selenium_3/grid_3/)
|
||||
([setup docs](https://www.selenium.dev/documentation/legacy/grid_3/setting_up_your_own_grid/)) instance by using the
|
||||
`--nodeconfig` server argument.
|
||||
|
||||
```bash
|
||||
appium server --nodeconfig /path/to/nodeconfig.json --base-path=/wd/hub
|
||||
```
|
||||
|
||||
In the referenced config file you have to define the `browserName`, `version` and `platform`
|
||||
capabilities and based on these parameters the grid will re-direct your test to the right device.
|
||||
You will also need to configure your host details and the Selenium Grid details. For a full list of
|
||||
all parameters and descriptions see
|
||||
[here](https://www.selenium.dev/documentation/legacy/selenium_3/grid_setup/).
|
||||
|
||||
Once you start the Appium server it will register with the grid, and you will see your device on
|
||||
the grid console page:
|
||||
|
||||
`http://**\<grid-ip-adress\>**:**\<grid-port\>**/grid/console`
|
||||
|
||||
### Example Grid Node Configuration JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"capabilities":
|
||||
[
|
||||
{
|
||||
"browserName": "<e.g._iPhone5_or_iPad4>",
|
||||
"version":"<version_of_iOS_e.g._7.1>",
|
||||
"maxInstances": 1,
|
||||
"platform":"<platform_e.g._MAC_or_ANDROID>"
|
||||
}
|
||||
],
|
||||
"configuration":
|
||||
{
|
||||
"cleanUpCycle":2000,
|
||||
"timeout":30000,
|
||||
"proxy": "org.openqa.grid.selenium.proxy.DefaultRemoteProxy",
|
||||
"url":"http://<host_name_appium_server_or_ip-address_appium_server>:<appium_port>/wd/hub",
|
||||
"host": "<host_name_appium_server_or_ip-address_appium_server>",
|
||||
"port": <appium_port>,
|
||||
"maxSession": 1,
|
||||
"register": true,
|
||||
"registerCycle": 5000,
|
||||
"hubPort": <grid_port>,
|
||||
"hubHost": "<Grid_host_name_or_grid_ip-address>",
|
||||
"hubProtocol": "<Protocol_of_Grid_defaults_to_http>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `url`, `host`, and `port` are not given, the config will be auto updated to point to
|
||||
`localhost:<appium-port>`.
|
||||
|
||||
If your Appium server is running on a different machine to your Selenium Grid server, make sure you
|
||||
use an external name/IP address in your `host` and `url` configuration; `localhost` and `127.0.0.1`
|
||||
will prevent Selenium Grid from connecting correctly.
|
||||
|
||||
@@ -31,10 +31,6 @@ installing it only installs the core Appium server, without any drivers.
|
||||
|
||||
When it comes to installing Appium 2 drivers, there are several approaches you can take:
|
||||
|
||||
* Add the `--drivers` flag when installing Appium, for example:
|
||||
```bash
|
||||
npm i -g appium --drivers=xcuitest,uiautomator2
|
||||
```
|
||||
* Use the [Appium Extension CLI](../reference/cli/extensions.md), for example:
|
||||
```bash
|
||||
appium driver install uiautomator2
|
||||
|
||||
@@ -85,3 +85,118 @@ Turn on multiple insecure features for specific drivers:
|
||||
```bash
|
||||
appium --allow-insecure=uiautomator2:adb_shell,xcuitest:get_server_logs
|
||||
```
|
||||
|
||||
## Restricting Remote App URLs
|
||||
|
||||
Several drivers accept a URL instead of a local path in capabilities such as `appium:app`, in which
|
||||
case the Appium server downloads the application package from that URL before starting the session.
|
||||
By default, any `http:` or `https:` URL is accepted. Server hosts who want to control which URLs
|
||||
their server is allowed to download from can do so with the `--app-url-rules` argument (or the
|
||||
`server.app-url-rules` property in a [configuration file](./config.md)). Its value is a JSON object
|
||||
(or a path to a JSON file) with any of the following properties:
|
||||
|
||||
|<div style="width:10em">Rule</div>|Description|Default|
|
||||
|----------------------------------|-----------|-------|
|
||||
|`allow`|List of hostname patterns, IP addresses, or CIDR subnets. If non-empty, a hostname or its resolved address must match _at least one_|`[]`|
|
||||
|`deny`|List of hostname patterns, IP addresses, or CIDR subnets. A matching hostname or resolved address is rejected, even if an `allow` rule also matches|`[]`|
|
||||
|`httpsOnly`|Only accept `https:` URLs|`false`|
|
||||
|`allowCredentials`|Accept URLs containing a username and/or password (e.g. `https://user:pass@host/app.apk`)|`true`|
|
||||
|`maxRedirects`|Maximum number of HTTP redirects to follow while downloading. Set to `0` to reject any redirect|`21`|
|
||||
|
||||
Hostnames are normalized (lowercased, converted to Punycode, trailing dot removed) before matching
|
||||
and resolved on every download. Address rules apply to literal addresses and all dynamically
|
||||
resolved IPv4/IPv6 addresses. The rules are also applied to every redirect, so redirects cannot be
|
||||
used to escape them. If a URL violates any rule, the session is not created and a generic error
|
||||
is returned. The violated rule is deliberately neither reported to the client nor written to the
|
||||
server log, since server logs are often handed over to clients as well. URL schemes, ports, paths,
|
||||
queries, and fragments are not matched.
|
||||
|
||||
Hostname patterns use the [picomatch](https://github.com/micromatch/picomatch#globbing-features)
|
||||
glob syntax and are matched against the whole hostname:
|
||||
|
||||
|Pattern|Matches|
|
||||
|-------|-------|
|
||||
|`artifacts.example.com`|Exactly this hostname|
|
||||
|`*.example.com`|Any subdomain of `example.com` (including nested ones, like `a.b.example.com`), but not `example.com` itself|
|
||||
|`*example.com`|Any hostname ending with `example.com`, including `example.com` itself|
|
||||
|`apps.{staging,prod}.example.com`|`apps.staging.example.com` and `apps.prod.example.com`|
|
||||
|`build-?.example.com`|`build-1.example.com`, `build-a.example.com`, etc.|
|
||||
|`node[0-9].example.com`|`node0.example.com` to `node9.example.com`|
|
||||
|
||||
IP address rules match a single address (`192.0.2.10`, `2001:db8::1`) or a whole subnet in CIDR
|
||||
notation (`10.0.0.0/8`, `2001:db8::/32`). IPv4 rules also match IPv4-mapped IPv6 addresses
|
||||
(e.g. `::ffff:10.1.2.3`).
|
||||
|
||||
!!! note
|
||||
|
||||
These rules only apply to URLs the server downloads via the standard app configuration helper,
|
||||
which is used for `appium:app` and similar capabilities by all official drivers. Drivers may
|
||||
define custom capabilities with URLs that are processed differently and thus are not covered.
|
||||
|
||||
!!! note
|
||||
|
||||
If the server downloads applications through an HTTP proxy (configured via the `HTTP_PROXY`,
|
||||
`HTTPS_PROXY` and `NO_PROXY` environment variables), the proxy resolves the destination host
|
||||
itself, so rules containing IP addresses or subnets cannot be enforced for proxied downloads.
|
||||
Such downloads are rejected; either exclude the download hosts from proxying via `NO_PROXY`
|
||||
or only use hostname-based rules.
|
||||
|
||||
### Examples
|
||||
|
||||
Only allow HTTPS downloads from an internal artifact server, without credentials in the URL and
|
||||
without following redirects:
|
||||
|
||||
```bash
|
||||
appium --app-url-rules='{"allow": ["artifacts.example.com", "10.0.0.0/8"], "httpsOnly": true, "allowCredentials": false, "maxRedirects": 0}'
|
||||
```
|
||||
|
||||
The same configuration as part of a config file:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
app-url-rules:
|
||||
allow:
|
||||
- 'artifacts.example.com'
|
||||
- '10.0.0.0/8'
|
||||
httpsOnly: true
|
||||
allowCredentials: false
|
||||
maxRedirects: 0
|
||||
```
|
||||
|
||||
Allow downloads from any subdomain of `example.com` and from two IPv6 subnets, except from the
|
||||
staging environment:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
app-url-rules:
|
||||
allow:
|
||||
- '*.example.com'
|
||||
- '2001:db8:1::/48'
|
||||
- '2001:db8:2::/48'
|
||||
deny:
|
||||
- '*.staging.example.com'
|
||||
```
|
||||
|
||||
Allow downloads from anywhere, except from the server itself, private networks, link-local
|
||||
addresses (such as cloud metadata services), and any `.internal` hostname:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
app-url-rules:
|
||||
deny:
|
||||
- 'localhost'
|
||||
- '*.internal'
|
||||
- '127.0.0.0/8'
|
||||
- '10.0.0.0/8'
|
||||
- '172.16.0.0/12'
|
||||
- '192.168.0.0/16'
|
||||
- '169.254.0.0/16'
|
||||
- '::1/128'
|
||||
- 'fc00::/7'
|
||||
- 'fe80::/10'
|
||||
```
|
||||
|
||||
The rules in the last example are applied to the addresses each hostname resolves to, so they
|
||||
cannot be bypassed via DNS. Note that the `deny` list also blocks any download from the server's
|
||||
own private network, so a private artifact server must be excluded from the `deny` rules in order
|
||||
to work.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
hide:
|
||||
- toc
|
||||
|
||||
title: SSL/TLS/SPDY Support
|
||||
title: SSL/TLS Support
|
||||
---
|
||||
|
||||
Appium 2.2 introduces the option to start the Appium server with SSL/TLS support.
|
||||
@@ -19,15 +19,14 @@ Both arguments must be provided and should contain paths to a valid
|
||||
[X509 PEM](https://www.ssl.com/guide/pem-der-crt-and-cer-x-509-encodings-and-conversions/)
|
||||
certificate and its corresponding private key.
|
||||
|
||||
After the server is started use the `https` protocol and a client supporting SSL/TLS or
|
||||
[SPDY](https://en.wikipedia.org/wiki/SPDY) to communicate to it.
|
||||
After the server is started use the `https` protocol and a client supporting SSL/TLS to
|
||||
communicate to it.
|
||||
|
||||
### Supported Features
|
||||
|
||||
Once a secure server socket is established it supports the following protocols:
|
||||
`['h2', 'spdy/3.1', 'spdy/3', 'spdy/2', 'http/1.1', 'http/1.0']`. See
|
||||
[the SPDY node module documentation](https://www.npmjs.com/package/spdy) to get more details about
|
||||
its features. All insecure client connections will be rejected by the server.
|
||||
Once a secure server socket is established it uses Node.js' native
|
||||
[`https`](https://nodejs.org/api/https.html) module to serve connections. All insecure client
|
||||
connections will be rejected by the server.
|
||||
|
||||
### Self-Signed Certificates
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ So, for example, the `Find Element` command corresponds to an HTTP `POST` reques
|
||||
endpoint `/session/:sessionid/element` (where in this case, `:sessionid` is a placeholder for the
|
||||
unique session ID generated by the server in a previous call to `Create Session`).
|
||||
|
||||
If the client disconnects before Appium finishes sending a `Create Session` response and the
|
||||
request has no `X-Idempotency-Key`, Appium deletes the new session once driver setup completes.
|
||||
Setup is not interrupted. With an accepted idempotency key, a retry that arrives before setup
|
||||
finishes waits for the same session ID, or the same error if setup failed. If the original client
|
||||
and all waiting retries disconnect before setup finishes, Appium deletes the new session and
|
||||
discards its cached result; a later retry starts a new session. Otherwise, Appium keeps the session
|
||||
and caches its response for replay while it remains in the bounded, in-memory idempotency cache.
|
||||
Disconnecting after a response was sent, or during a command in an existing session, does not
|
||||
automatically end that session. Use `Delete Session` when you are done.
|
||||
|
||||
This information is primarily useful for people developing technology that works with the WebDriver
|
||||
spec. It's not particularly useful for people trying to write Appium or Selenium tests. When you
|
||||
write an Appium test, you want to use a programming language you're familiar with. Luckily, there
|
||||
|
||||
@@ -19,7 +19,7 @@ Appium, called `BaseDriver`. You could have something very close to a "working"
|
||||
very simple lines of code:
|
||||
|
||||
```js
|
||||
import BaseDriver from '@appium/base-driver'
|
||||
import {BaseDriver} from '@appium/base-driver'
|
||||
|
||||
class MyNewDriver extends BaseDriver {
|
||||
}
|
||||
|
||||
@@ -106,6 +106,48 @@ Retrieves the [execute methods](../../guides/execute-methods.md) supported in th
|
||||
origin (driver-specific or plugin-specific). Refer to [the type definition file](https://github.com/appium/appium/blob/master/packages/types/lib/command.ts)
|
||||
for a detailed structure of this object.
|
||||
|
||||
### getCurrentAppiumContext
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/context
|
||||
```
|
||||
|
||||
Retrieves the active application context.
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - the name of the active context
|
||||
|
||||
### setAppiumContext
|
||||
|
||||
```
|
||||
POST /session/:sessionId/appium/context
|
||||
```
|
||||
|
||||
Sets an application context as the active context.
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`name`|Name of the context to set as the active one|string|
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### getAppiumContexts
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/contexts
|
||||
```
|
||||
|
||||
Retrieves all available application contexts.
|
||||
|
||||
#### Response
|
||||
|
||||
`string[]` - the names of available contexts
|
||||
|
||||
### getLogEvents
|
||||
|
||||
```
|
||||
@@ -392,3 +434,71 @@ Retrieves data from a directory on the device under test.
|
||||
#### Response
|
||||
|
||||
`string` - the Base64-encoded zip file of the directory contents
|
||||
|
||||
### getAppiumRotation
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/device/rotation
|
||||
```
|
||||
|
||||
Retrieves the current spatial orientation of the device under test.
|
||||
|
||||
#### Response
|
||||
|
||||
`Rotation` - an object with the following properties:
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`x`|Degrees by which the device is rotated on its X axis|number|
|
||||
|`y`|Degrees by which the device is rotated on its Y axis|number|
|
||||
|`z`|Degrees by which the device is rotated on its Z axis|number|
|
||||
|
||||
### setAppiumRotation
|
||||
|
||||
```
|
||||
POST /session/:sessionId/appium/device/rotation
|
||||
```
|
||||
|
||||
Sets the spatial orientation of the device under test.
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`x`|Degrees by which the device is rotated on its X axis|number|
|
||||
|`y`|Degrees by which the device is rotated on its Y axis|number|
|
||||
|`z`|Degrees by which the device is rotated on its Z axis|number|
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### getAppiumOrientation
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/device/orientation
|
||||
```
|
||||
|
||||
Retrieves the current orientation of the device under test.
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - either `PORTRAIT` or `LANDSCAPE`
|
||||
|
||||
### setAppiumOrientation
|
||||
|
||||
```
|
||||
POST /session/:sessionId/appium/device/orientation
|
||||
```
|
||||
|
||||
Sets the orientation of the device under test.
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`orientation`|New device orientation. Supported values are `PORTRAIT` or `LANDSCAPE`.|string|
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
@@ -10,125 +10,6 @@ title: JSON Wire Protocol
|
||||
The following is a list of legacy [JSON Wire Protocol (JSONWP)](https://www.selenium.dev/documentation/legacy/json_wire_protocol/)
|
||||
endpoints supported in Appium.
|
||||
|
||||
### getSession
|
||||
|
||||
```
|
||||
GET /session/:sessionId
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionid)
|
||||
|
||||
Retrieves the capabilities of the current session.
|
||||
|
||||
Appium implements a modified version of this endpoint. If the `appium:eventTimings` capability is
|
||||
set to `true`, the returned result will additionally include the `events` key, whose value contains
|
||||
the event history and timings.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
For retrieving capabilities, please use [`getAppiumSessionCapabilities`](./appium.md#getappiumsessioncapabilities).<br />
|
||||
For retrieving event history, please use [`getLogEvents`](./appium.md#getlogevents).
|
||||
|
||||
#### Response
|
||||
|
||||
`Capabilities` - an object containing the session capabilities, and the event history (if applicable)
|
||||
|
||||
### availableIMEEngines
|
||||
|
||||
```
|
||||
GET /session/:sessionId/ime/available_engines
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/ime/available_engines](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidimeavailable_engines)
|
||||
|
||||
Retrieves all IME (input method editor) engines available on the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
In the future, this endpoint will be moved to the [UiAutomator2 and Espresso drivers](../../ecosystem/drivers.md)
|
||||
|
||||
#### Response
|
||||
|
||||
`string[]` - a list of available IME engines
|
||||
|
||||
### getActiveIMEEngine
|
||||
|
||||
```
|
||||
GET /session/:sessionId/ime/active_engine
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/ime/active_engine](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidimeactive_engine)
|
||||
|
||||
Retrieves the name of the active IME engine.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
In the future, this endpoint will be moved to the UiAutomator2 and Espresso drivers
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - the name of the active IME engine
|
||||
|
||||
### isIMEActivated
|
||||
|
||||
```
|
||||
GET /session/:sessionId/ime/activated
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/ime/activated](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidimeactivated)
|
||||
|
||||
Determines if IME input is available and active.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
In the future, this endpoint will be moved to the UiAutomator2 and Espresso drivers
|
||||
|
||||
#### Response
|
||||
|
||||
`boolean` - `true` if IME is active, otherwise `false`
|
||||
|
||||
### deactivateIMEEngine
|
||||
|
||||
```
|
||||
POST /session/:sessionId/ime/deactivate
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/ime/deactivate](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidimedeactivate)
|
||||
|
||||
Deactivates the currently active IME engine.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
In the future, this endpoint will be moved to the UiAutomator2 and Espresso drivers
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### activateIMEEngine
|
||||
|
||||
```
|
||||
POST /session/:sessionId/ime/activate
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/ime/activate](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidimeactivate)
|
||||
|
||||
Activates an IME engine.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
In the future, this endpoint will be moved to the UiAutomator2 and Espresso drivers
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`engine`|Name of the IME engine to activate|string|
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### getOrientation
|
||||
|
||||
```
|
||||
@@ -139,6 +20,10 @@ GET /session/:sessionId/orientation
|
||||
|
||||
Retrieves the current orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getAppiumOrientation`](./appium.md#getappiumorientation) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - either `PORTRAIT` or `LANDSCAPE`
|
||||
@@ -153,6 +38,10 @@ POST /session/:sessionId/orientation
|
||||
|
||||
Sets the orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`setAppiumOrientation`](./appium.md#setappiumorientation) instead
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
@@ -162,53 +51,3 @@ Sets the orientation of the device under test.
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### getGeoLocation
|
||||
|
||||
```
|
||||
GET /session/:sessionId/location
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/location](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidlocation)
|
||||
|
||||
Retrieves the current location of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use driver-specific extension methods such as `mobile: getGeoLocation` or
|
||||
`mobile: getSimulatedLocation`
|
||||
|
||||
#### Response
|
||||
|
||||
`Location` - an object with the following properties:
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`altitude`|Altitude of the device location|number|
|
||||
|`latitude`|Latitude of the device location|number|
|
||||
|`longitude`|Longitude of the device location|number|
|
||||
|
||||
### setGeoLocation
|
||||
|
||||
```
|
||||
POST /session/:sessionId/location
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/location](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidlocation)
|
||||
|
||||
Sets the current location of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use driver-specific extension methods such as `mobile: setGeoLocation` or
|
||||
`mobile: setSimulatedLocation`
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`location`|New device latitude, longitude and altitude|[`Location`](#response_6)|
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
@@ -20,6 +20,10 @@ GET /session/:sessionId/rotation
|
||||
|
||||
Retrieves the current spatial orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getAppiumRotation`](./appium.md#getappiumrotation) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`Rotation` - an object with the following properties:
|
||||
@@ -40,6 +44,10 @@ POST /session/:sessionId/rotation
|
||||
|
||||
Sets the spatial orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`setAppiumRotation`](./appium.md#setappiumrotation) instead
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
@@ -62,6 +70,10 @@ GET /session/:sessionId/context
|
||||
|
||||
Retrieves the active application context.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getCurrentAppiumContext`](./appium.md#getcurrentappiumcontext) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - the name of the active context
|
||||
@@ -76,6 +88,10 @@ POST /session/:sessionId/context
|
||||
|
||||
Sets an application context as the active context.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`setAppiumContext`](./appium.md#setappiumcontext) instead
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
@@ -96,56 +112,10 @@ GET /session/:sessionId/contexts
|
||||
|
||||
Retrieves all available application contexts.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getAppiumContexts`](./appium.md#getappiumcontexts) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`string[]` - the names of available contexts
|
||||
|
||||
### getNetworkConnection
|
||||
|
||||
```
|
||||
GET /session/:sessionId/network_connection
|
||||
```
|
||||
|
||||
> MJSONWP documentation: [Device Modes](https://github.com/SeleniumHQ/mobile-spec/blob/master/spec-draft.md#device-modes)
|
||||
|
||||
Retrieves the current state of network types (data, Wi-Fi, airplane mode).
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use driver-specific extension methods such as `mobile: getConnectivity`
|
||||
|
||||
#### Response
|
||||
|
||||
`NetworkConnectionState` - a number indicating the current network state:
|
||||
|
||||
|Value|Data|Wi-Fi|Airplane Mode|
|
||||
|--|--|--|--|
|
||||
|`0`|OFF|OFF|OFF|
|
||||
|`1`|OFF|OFF|ON|
|
||||
|`2`|OFF|ON|OFF|
|
||||
|`4`|ON|OFF|OFF|
|
||||
|`6`|ON|ON|OFF|
|
||||
|
||||
### setNetworkConnection
|
||||
|
||||
```
|
||||
POST /session/:sessionId/network_connection
|
||||
```
|
||||
|
||||
> MJSONWP documentation: [Device Modes](https://github.com/SeleniumHQ/mobile-spec/blob/master/spec-draft.md#device-modes)
|
||||
|
||||
Sets the state of network types (data, Wi-Fi, airplane mode).
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use driver-specific extension methods such as `mobile: setConnectivity`
|
||||
|
||||
#### Parameters
|
||||
|
||||
|<div style="width:6em">Name</div>|Description|<div style="width:18em">Type</div>|
|
||||
|--|--|--|
|
||||
|`parameters`|Object containing the `type` key, whose value is the desired network state|`{"type": `[`NetworkConnectionState`](#response_5)`}`|
|
||||
|
||||
#### Response
|
||||
|
||||
[`NetworkConnectionState`](#response_5) - the new network state
|
||||
|
||||
@@ -169,10 +169,17 @@ Modifies the [`createSession`](./webdriver.md#createsession) endpoint:
|
||||
All endpoints for this plugin can be invoked without creating a session, allowing you to prepare
|
||||
your test environment in advance.
|
||||
|
||||
!!! info
|
||||
|
||||
Prior to plugin version 1.2.0, all endpoints below were mounted under the `/storage` prefix
|
||||
(e.g. `/storage/add`) instead of `/appium/storage`. The `/storage`-prefixed routes are still
|
||||
available for backward compatibility, but are deprecated and will be removed in a future
|
||||
version of the plugin.
|
||||
|
||||
### addStorageItem
|
||||
|
||||
```
|
||||
POST /storage/add
|
||||
POST /appium/storage/add
|
||||
```
|
||||
|
||||
Adds a new file to the storage.
|
||||
@@ -198,8 +205,8 @@ Example:
|
||||
```json
|
||||
{
|
||||
"ws": {
|
||||
"stream": "/storage/add/ccc963411b2621335657963322890305ebe96186/stream",
|
||||
"events": "/storage/add/ccc963411b2621335657963322890305ebe96186/events"
|
||||
"stream": "/appium/storage/add/ccc963411b2621335657963322890305ebe96186/stream",
|
||||
"events": "/appium/storage/add/ccc963411b2621335657963322890305ebe96186/events"
|
||||
},
|
||||
"ttlMs": 300000
|
||||
}
|
||||
@@ -208,7 +215,7 @@ Example:
|
||||
### deleteStorageItem
|
||||
|
||||
```
|
||||
POST /storage/delete
|
||||
POST /appium/storage/delete
|
||||
```
|
||||
|
||||
Deletes a file in the storage.
|
||||
@@ -227,7 +234,7 @@ the storage, or if the delete request is invalid.
|
||||
### listStorageItems
|
||||
|
||||
```
|
||||
GET /storage/list
|
||||
GET /appium/storage/list
|
||||
```
|
||||
|
||||
List all files present in the storage.
|
||||
@@ -245,7 +252,7 @@ List all files present in the storage.
|
||||
### resetStorage
|
||||
|
||||
```
|
||||
POST /storage/reset
|
||||
POST /appium/storage/reset
|
||||
```
|
||||
|
||||
Deletes all uploaded files and stops any incomplete uploads.
|
||||
|
||||
@@ -25,18 +25,11 @@ POST /session
|
||||
|
||||
Creates a new WebDriver session.
|
||||
|
||||
Appium implements a modified version of this endpoint for historical reasons. While the W3C
|
||||
endpoint only accepts 1 parameter, Appium's implementation allows up to 3 parameters, as this was
|
||||
required by the legacy JSON Wire Protocol (JSONWP). Since Appium 2, the JSONWP format is no longer
|
||||
supported, and any of the 3 parameters can be used to specify the W3C capabilities.
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`w3cCapabilities1?`|Capabilities of the new session|`W3CDriverCaps`|
|
||||
|`w3cCapabilities2?`|Another location for the new session capabilities (legacy)|`W3CDriverCaps`|
|
||||
|`w3cCapabilities?`|Another location for the new session capabilities (legacy)|`W3CDriverCaps`|
|
||||
|`capabilities?`|Capabilities of the new session|`W3CDriverCaps`|
|
||||
|
||||
#### Response
|
||||
|
||||
@@ -91,14 +84,19 @@ GET /session/:sessionId/timeouts
|
||||
|
||||
Retrieves the timeout values of the current session.
|
||||
|
||||
Appium implements a modified version of this endpoint, by also returning the value of the `command`
|
||||
timeout for supported drivers and contexts.
|
||||
|
||||
#### Response
|
||||
|
||||
`GetTimeoutsResult` - an object with the following properties:
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`command`|Command timeout|number|
|
||||
|`script`|Script timeout|number|
|
||||
|`pageLoad`|Page load timeout|number|
|
||||
|`implicit`|Implicit wait timeout|number|
|
||||
|`command`|Appium command timeout|number|
|
||||
|
||||
### timeouts
|
||||
|
||||
@@ -110,13 +108,17 @@ POST /session/:sessionId/timeouts
|
||||
|
||||
Sets the timeout values of the current session.
|
||||
|
||||
Appium implements a modified version of this endpoint by adding the optional `command` parameter
|
||||
for supported drivers and contexts.
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`script?`|Script timeout (in milliseconds)|number|
|
||||
|`implicit?`|Implicit wait timeout (in milliseconds)|number|
|
||||
|`pageLoad?`|Page load timeout (in milliseconds)|number|
|
||||
|`script?`|Script timeout (in milliseconds)|number|
|
||||
|`command?`|Appium command timeout (in milliseconds)|number|
|
||||
|
||||
#### Response
|
||||
|
||||
|
||||
@@ -158,13 +158,13 @@ extensions include scripts.
|
||||
#### Usage
|
||||
|
||||
```
|
||||
appium {driver|plugin} run <extension-name> <script-name> [script-args]
|
||||
appium {driver|plugin} run <extension-name> [<script-name> [<script-args>]]
|
||||
```
|
||||
|
||||
|Argument|Description|
|
||||
|--|--|
|
||||
|`extension-name`|The short name of the installed extension|
|
||||
|`script-name`|The name of the script to be run|
|
||||
|`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|
|
||||
|
||||
#### Options
|
||||
@@ -181,6 +181,12 @@ appium {driver|plugin} run <extension-name> <script-name> [script-args]
|
||||
appium driver run uiautomator2 reset
|
||||
```
|
||||
|
||||
- List all available scripts included in the XCUITest driver:
|
||||
|
||||
```
|
||||
appium driver run xcuitest
|
||||
```
|
||||
|
||||
## `update`
|
||||
|
||||
Updates one or more extensions. Only supported for extensions installed via `npm`. By default,
|
||||
|
||||
@@ -14,6 +14,7 @@ they can only be enabled using the wildcard (`*`) prefix:
|
||||
| Feature Name | Description |
|
||||
|-------------------|-------------------------------------------------------------------------------|
|
||||
|`session_discovery`|Allows retrieving the list of active server sessions via `GET /appium/sessions`|
|
||||
|`cors` |Allows web browser connections to the server from any host |
|
||||
|
||||
|
||||
Of course, Appium drivers and plugins are free to define additional insecure features of their own.
|
||||
|
||||
@@ -27,9 +27,9 @@ appium
|
||||
|<div style="width:15em">Argument</div>|Description|Type|<div style="width:7em">Default</div>|
|
||||
|--|--|--|--|
|
||||
|`--address`, `-a`|IPv4/IPv6 address to listen on|string|`0.0.0.0`|
|
||||
|`--allow-cors`|Allow web browser connections from any host|boolean|`false`|
|
||||
|`--allow-insecure`|List of [insecure features](../../guides/security.md) that should be allowed in this server's sessions. Individual features can be overridden by `--deny-insecure`. Has no effect in combination with `--relaxed-security`.|array<string>|`[]`|
|
||||
|`--allow-unknown-args`|Do not exit if unrecognized command-line arguments are passed to the server; ignore them instead. Useful when the Appium CLI is wrapped by external tooling that appends extra flags.|boolean|`false`|
|
||||
|`--app-url-rules`|Rules that remote application URLs (e.g. in the `appium:app` capability) must satisfy before the server downloads them. See the [Restricting Remote App URLs](../../guides/security.md#restricting-remote-app-urls) guide for details. Can be a path to a JSON file or raw JSON.|object||
|
||||
|`--base-path`, `-pa`|Base path to use as the prefix for all webdriver routes running on the server|string|`""`|
|
||||
|`--callback-address`, `-ca`|Callback IP address|string|`0.0.0.0`|
|
||||
|`--callback-port`, `-cp`|Callback port|integer|`4723`|
|
||||
@@ -44,7 +44,7 @@ appium
|
||||
|`--local-timezone`|Use local timezone for log timestamps|boolean|`false`|
|
||||
|`--log`, `-g`|Path to a file where the server logs should be output. This does not affect output on the console.|string||
|
||||
|`--log-filters`|List of log filtering rules. See the [log filtering guide](../../guides/log-filters.md) for details.|array||
|
||||
|`--log-level`|The log level for the server logs. Supported values are `debug`, `info`, `warn`, or `error`. Combining two supported values using a colon (e.g. `warn:debug`) allows to set separate log levels for the console and file outputs, respectively.|string|`debug`|
|
||||
|`--log-level`|The log level for the server logs. Supported values are `debug`, `info`, `warn`, or `error`. Combining two supported values using a colon (e.g. `warn:debug`) allows to set separate log levels for the console and file outputs, respectively.|string|`info`|
|
||||
|`--log-format`|The log format of the server logs. Supported values are `text`, `json`, or `pretty_json`. Setting the value to `json` disables colors.|string|`text`|
|
||||
|`--log-no-colors`|Disable colors in the server log|boolean|`false`|
|
||||
|`--log-timestamp`|Show timestamps in the server log|boolean|`false`|
|
||||
@@ -52,7 +52,6 @@ appium
|
||||
|`--max-ipc-data-size`|Maximum byte size of IPC message objects.|integer|`1048576` (1MB)|
|
||||
|`--max-ipc-topics`|Maximum number of IPC topics per session.|integer|`1000`|
|
||||
|`--no-perms-check`|Skip various permission checks on the server startup|boolean|`false`|
|
||||
|`--nodeconfig`|JSON configuration for registering Appium as a node with Selenium Grid 3|object||
|
||||
|`--plugin`|Plugin-specific configuration. Keys should correspond to plugin package names|object||
|
||||
|`--plugins-import-chunk-size`|Maximum number of plugins that can be imported in parallel on server startup|number|`7`|
|
||||
|`--port`, `-p`|Port to listen on|integer|`4723`|
|
||||
@@ -65,7 +64,7 @@ appium
|
||||
|`--tmp`|Absolute path to the directory used for temporary files|string|[`os.tmpdir()`](https://nodejs.org/api/os.html#ostmpdir)|
|
||||
|`--use-drivers`|List of drivers to activate. By default, all installed drivers are activated.|array<string>|`[]`|
|
||||
|`--use-plugins`|List of plugins to activate. By default, no plugins are activated. Set to `["all"]` to activate all installed plugins.|array<string>|`[]`|
|
||||
|`--webhook`, `-G`|URL for an HTTP listener where the server logs should be output. This does not affect output on the console.|string||
|
||||
|`--webhook`, `-G`|URL for an HTTP listener where the server logs should be output. This does not affect output on the console. A bare `host:port` is also accepted, which posts the logs to the root path over plain http; a value having neither a scheme nor a port falls back to `127.0.0.1:9003`.|string||
|
||||
|
||||
### Info Options
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ default value.
|
||||
| <div style="width:19em">Capability</div> | Description | Type | Default |
|
||||
|--|--|--|--|
|
||||
| `webSocketUrl` | Toggle support of the WebDriver BiDi protocol | `boolean` ||
|
||||
| `appium:eventTimings` (deprecated) | Toggle collection of [Event Timings](../../guides/event-timing.md). This capability is deprecated - please use the [`getLogEvents`](../api/appium.md#getlogevents) endpoint instead. | `boolean` ||
|
||||
| `appium:newCommandTimeout` | Number of seconds the Appium server should wait for clients to send commands before stopping the session. A value of `0` disables the timeout. | `number` | `60` |
|
||||
| `appium:printPageSourceOnFindFailure` | Toggle retrieval of the the page source and its printing to the Appium log, whenever a request to find an element fails | `boolean` ||
|
||||
|
||||
|
||||
@@ -17,3 +17,8 @@ authors:
|
||||
avatar: 'https://avatars.githubusercontent.com/u/4045041?v=4'
|
||||
slug: 'sai'
|
||||
url: 'https://saikrishna.tech'
|
||||
mykola:
|
||||
name: 'Mykola Mokhnach'
|
||||
description: 'Appium Core Maintainer'
|
||||
avatar: https://avatars.githubusercontent.com/u/7767781?v=4
|
||||
slug: 'mykola'
|
||||
|
||||
@@ -8,11 +8,13 @@ date: 2025-11-25
|
||||
# Announcing LambdaTest as Appium's Newest Development Partner
|
||||
|
||||
As part of our ongoing effort to recognize organizations that meaningfully support the Appium project through dedicated maintainer time, we are excited to welcome our newest Development Partner: LambdaTest!
|
||||
LambdaTest has long been an active participant in the automated testing ecosystem. Today, they are officially formalizing their commitment by investing people—dedicated engineers who will directly support Appium's development, maintenance, and ecosystem growth.
|
||||
This kind of hands-on contribution is crucial for ensuring Appium continues to evolve. We deeply value partners who empower their engineering teams to contribute code directly to open source rather than just sponsorship.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
LambdaTest has long been an active participant in the automated testing ecosystem. Today, they are officially formalizing their commitment by investing people—dedicated engineers who will directly support Appium's development, maintenance, and ecosystem growth.
|
||||
|
||||
This kind of hands-on contribution is crucial for ensuring Appium continues to evolve. We deeply value partners who empower their engineering teams to contribute code directly to open source rather than just sponsorship.
|
||||
|
||||
<div style="text-align:center;width:100%"><a href="https://lambdatest.com/?utm_source=appium.io&utm_medium=organic&utm_campaign=june_25&utm_term=sk&utm_content=webpage">
|
||||
<img src="/docs/en/latest/assets/images/sponsor-logo-lambdatest-dark.png#only-dark" width="300" />
|
||||
<img src="/docs/en/latest/assets/images/sponsor-logo-lambdatest-light.png#only-light" width="300" />
|
||||
@@ -20,15 +22,19 @@ This kind of hands-on contribution is crucial for ensuring Appium continues to e
|
||||
</div>
|
||||
|
||||
LambdaTest engineers are already contributing actively across several key Appium repositories. Their current focus includes:
|
||||
**appium-mcp** – Supporting Appium's implementation of the Model Context Protocol (MCP) to drive forward modern AI-assisted capabilities.
|
||||
**appium-remote-xpc** – Contributing to the native macOS/XPC-based remote execution layer, essential for iOS automation.
|
||||
**appium-tun-tap** – Assisting in the development and maintenance of network virtualization and tunneling components for advanced testing scenarios.
|
||||
|
||||
- [`appium-mcp`](https://github.com/appium/appium-mcp) – Supporting Appium's implementation of the Model Context Protocol (MCP) to drive forward modern AI-assisted capabilities.
|
||||
- [`appium-ios-remotexpc`](https://github.com/appium/appium-ios-remotexpc) – Contributing to the native macOS/XPC-based remote execution layer, essential for iOS automation.
|
||||
- [`appium-ios-tuntap`](https://github.com/appium/appium-ios-tuntap) – Assisting in the development and maintenance of network virtualization and tunneling components for advanced testing scenarios.
|
||||
|
||||
These contributions strengthen critical infrastructure within the Appium ecosystem and demonstrate LambdaTest's commitment to deep technical involvement in the project's evolution.
|
||||
About LambdaTest
|
||||
|
||||
**About LambdaTest**
|
||||
|
||||
LambdaTest’s platform already serves a massive community of Appium users. As a GenAI-powered Quality Engineering Platform, they provide a full-stack testing cloud with 10,000+ real mobile devices and 3,000+ browsers.
|
||||
|
||||
Beyond standard infrastructure, they are pushing the boundaries of test execution with AI-native test management and next-generation agents like KaneAI. By supporting Appium alongside Selenium, Playwright, and other major frameworks, LambdaTest helps teams orchestrate intelligent automation at cloud scale.
|
||||
|
||||
LambdaTest will continue contributing people, code, and development expertise to strengthen Appium's core functionality. Their commitment represents a significant investment into the future of the framework, and we're thrilled to have them collaborating with us at this deeper level.
|
||||
|
||||
The Appium project is proud to recognize LambdaTest as our newest official Development Partner. We can't wait to see what we'll build together!
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
authors:
|
||||
- mykola
|
||||
date: 2026-09-01
|
||||
---
|
||||
|
||||
# Announcing watchOS Simulator Support in the XCUITest Driver
|
||||
|
||||
We’re excited to announce that the XCUITest driver now supports automation of watchOS apps — a
|
||||
platform that’s been on Appium’s requested list for a long time.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
In order to automate watchOS, you'll need the following:
|
||||
|
||||
- XCUITest driver version 12.6.0 or later (WebDriverAgent 16.5.0 or later)
|
||||
- Xcode 15.4 or later
|
||||
- watchOS 10 or later
|
||||
|
||||
Important to note: ^^the driver currently only works with simulators — real devices are not
|
||||
compatible.^^
|
||||
|
||||
The good news: automating a watchOS app looks just like automating any other iOS/iPadOS app. The
|
||||
standard use-cases of finding and clicking on elements, retrieving the page source, using the
|
||||
Appium Inspector — all work as usual. The one exception here is standard W3C gesture/touch actions
|
||||
(tap/swipe via the Actions API), which are not supported.
|
||||
|
||||
On top of that, a few watchOS-specific extensions are available:
|
||||
|
||||
- `mobile: pressButton` — press the Digital Crown or Action button
|
||||
- `mobile: rotateDigitalCrown` — rotate the Digital Crown (since driver version 12.7.0)
|
||||
- `mobile: performHandGesture` — perform double-tap or wrist flick hand gestures (since driver
|
||||
version 12.7.0)
|
||||
|
||||
For full setup instructions, capabilities, and limitations, check out the
|
||||
[watchOS guide](https://appium.github.io/appium-xcuitest-driver/latest/guides/watchos/).
|
||||
|
||||
Happy testing!
|
||||
@@ -222,17 +222,24 @@ So you'll probably end up overriding `createSession`. You can do so by defining
|
||||
driver:
|
||||
|
||||
```js
|
||||
async createSession(jwpCaps, reqCaps, w3cCaps, otherDriverData) {
|
||||
const [sessionId, caps] = super.createSession(w3cCaps);
|
||||
async createSession(w3cCaps) {
|
||||
const [sessionId, caps] = await super.createSession(w3cCaps);
|
||||
// do your own stuff here
|
||||
return [sessionId, caps];
|
||||
}
|
||||
```
|
||||
|
||||
For legacy reasons, your function will receive old-style JSON Wire Protocol desired and required
|
||||
caps as the first two arguments. Given that the old protocol isn't supported anymore and clients
|
||||
have all been updated, you can instead only rely on the `w3cCaps` parameter. (For a discussion
|
||||
about what `otherDriverData` is about, see the section below on concurrent drivers).
|
||||
!!! warning "Deprecated"
|
||||
|
||||
```
|
||||
Older drivers may still define `createSession` with up to four parameters, e.g.
|
||||
`createSession(jwpCaps, reqCaps, w3cCaps, otherDriverData)`. This shape is a holdover from the
|
||||
retired JSON Wire Protocol, which required desired and required caps as the first two
|
||||
arguments; only the first W3C-shaped argument was ever used. The `otherDriverData` parameter is
|
||||
likewise deprecated in favor of [IPC](#send-messages-to-plugins-running-on-the-same-session) for
|
||||
coordinating with other concurrently-running sessions. New drivers should use the single-argument
|
||||
form shown above.
|
||||
```
|
||||
|
||||
You'll want to make sure to call `super.createSession` in order to get the session ID as well as
|
||||
the processed capabilities (note that capabilities are also set on `this.caps`; modifying `caps`
|
||||
@@ -522,14 +529,13 @@ route, then the route will not be proxied and instead will be handled by your dr
|
||||
example, we are avoiding proxying all `POST` routes that have the `appium` prefix.
|
||||
|
||||
Next, we have to set up the proxying itself. The way to do this is to use a special class from
|
||||
Appium called `JWProxy`. (The name means "JSON Wire Proxy" and is related to a legacy
|
||||
implementation of the protocol). You'll want to create a `JWProxy` object using the details required to
|
||||
connect to the remote server:
|
||||
Appium called `WebDriverProxy`. You'll want to create a `WebDriverProxy` object using the details
|
||||
required to connect to the remote server:
|
||||
|
||||
```js
|
||||
// import {JWProxy} from 'appium/driver';
|
||||
// import {WebDriverProxy} from 'appium/driver';
|
||||
|
||||
const proxy = new JWProxy({
|
||||
const proxy = new WebDriverProxy({
|
||||
server: 'remote.server',
|
||||
port: 1234,
|
||||
base: '/',
|
||||
@@ -541,8 +547,8 @@ this.proxyCommand = proxy.command.bind(proxy);
|
||||
|
||||
Here we are creating a proxy object and assigning some of its methods to `this` under the names
|
||||
`proxyReqRes` and `proxyCommand`. This is required for Appium to use the proxy, so don't forget
|
||||
this step! The `JWProxy` has a variety of other options which you can check out in the source code,
|
||||
as well. (TODO: publish options as API docs and link here).
|
||||
this step! The `WebDriverProxy` has a variety of other options which you can check out in the
|
||||
source code as well.
|
||||
|
||||
Finally, we need a way to tell Appium when the proxy is active. For your driver it might always
|
||||
be active, or it might only be active when in a certain context. You can define the logic as an
|
||||
@@ -799,6 +805,14 @@ that multiple simultaneous sessions don't use the same resources:
|
||||
3. Have each driver express what resources it is using, then examine currently-used resources from
|
||||
other drivers when a new session begins.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
```
|
||||
The `driverData`-based mechanism described below is deprecated. Prefer
|
||||
[IPC](#send-messages-to-plugins-running-on-the-same-session) for coordinating resources across
|
||||
concurrently-running sessions instead.
|
||||
```
|
||||
|
||||
To support this third strategy, you can implement `get driverData` in your driver to return what
|
||||
sorts of resources your driver is currently using, for example:
|
||||
|
||||
@@ -810,7 +824,7 @@ get driverData() {
|
||||
|
||||
Now, when a new session is started on your driver, the `driverData` response from any other
|
||||
simultaneously running drivers (of the same type) will also be included, as the last parameter of
|
||||
the `createSession` method:
|
||||
the deprecated, multi-argument form of the `createSession` method:
|
||||
|
||||
```js
|
||||
async createSession(jwpCaps, reqCaps, w3cCaps, driverData)
|
||||
|
||||
@@ -483,9 +483,9 @@ 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)
|
||||
in a variety of ways). But, for example, you could add new Express middleware 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:
|
||||
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:
|
||||
|
||||
- `expressApp`: the Express app object
|
||||
- `httpServer`: the Node HTTP server object
|
||||
@@ -498,6 +498,25 @@ results you'll need to test! Warning: this should be considered an advanced feat
|
||||
knowledge of Express, as well as the care not to do anything that could affect the operation of
|
||||
other parts of the Appium server!
|
||||
|
||||
`updateServer` runs _after_ Appium's own routes are registered, so `expressApp.use(...)` here
|
||||
won't see requests to paths Appium already owns — Express matches middleware and routes in
|
||||
registration order. For middleware that must see requests to those routes too (e.g. logging or
|
||||
auth), use `httpServer.frontRouter` instead: Appium mounts this
|
||||
[Router](https://expressjs.com/en/5x/api/router/) before any route is registered, so anything
|
||||
attached to it — even from inside `updateServer` — still runs ahead of route matching. Note this
|
||||
is still _after_ Appium's own baseline middleware (CORS, WebSocket upgrade handling, body
|
||||
parsing, etc.), so it won't see requests Appium itself has already rejected, e.g. a CORS
|
||||
preflight failure or a body that failed to parse:
|
||||
|
||||
```js
|
||||
static async updateServer(expressApp, httpServer, cliArgs) {
|
||||
httpServer.frontRouter.use((req, res, next) => {
|
||||
console.log(`Incoming request: ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Handle unexpected session shutdown
|
||||
|
||||
When developing a plugin you may want to add some cleanup logic for when a session ends. You would
|
||||
|
||||
@@ -130,7 +130,7 @@ appium driver install --source=npm appium-flutter-integration-driver
|
||||
appium driver install --source=npm appium-lg-webos-driver
|
||||
```
|
||||
|
||||
### [Linux](https://github.com/fantonglang/appium-linux-driver)
|
||||
### [Linux (@fantonglang)](https://github.com/fantonglang/appium-linux-driver)
|
||||
|
||||
!!! warning
|
||||
|
||||
@@ -149,6 +149,16 @@ yarn install
|
||||
node ./
|
||||
```
|
||||
|
||||
### [Linux (@Itsmeaj)](https://github.com/Itsmeaj/appium)
|
||||
|
||||
- Target: Linux desktop applications on X11 and Wayland
|
||||
- Mode: Native
|
||||
- Supported by: `@Itsmeaj`
|
||||
|
||||
```sh title="Install This Driver"
|
||||
appium driver install --source=npm @itsmeaj/appium-linux-driver
|
||||
```
|
||||
|
||||
### [NovaWindows](https://github.com/AutomateThePlanet/appium-novawindows-driver)
|
||||
|
||||
!!! info
|
||||
|
||||
@@ -107,6 +107,48 @@ Retrieves the [execute methods](../../guides/execute-methods.md) supported in th
|
||||
origin (driver-specific or plugin-specific). Refer to [the type definition file](https://github.com/appium/appium/blob/master/packages/types/lib/command.ts)
|
||||
for a detailed structure of this object.
|
||||
|
||||
### getCurrentAppiumContext
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/context
|
||||
```
|
||||
|
||||
Retrieves the active application context.
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - the name of the active context
|
||||
|
||||
### setAppiumContext
|
||||
|
||||
```
|
||||
POST /session/:sessionId/appium/context
|
||||
```
|
||||
|
||||
Sets an application context as the active context.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
| ------ | -------------------------------------------- | ------ |
|
||||
| `name` | Name of the context to set as the active one | string |
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### getAppiumContexts
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/contexts
|
||||
```
|
||||
|
||||
Retrieves all available application contexts.
|
||||
|
||||
#### Response
|
||||
|
||||
`string[]` - the names of available contexts
|
||||
|
||||
### getLogEvents
|
||||
|
||||
```
|
||||
@@ -393,3 +435,71 @@ Retrieves data from a directory on the device under test.
|
||||
#### Response
|
||||
|
||||
`string` - the Base64-encoded zip file of the directory contents
|
||||
|
||||
### getAppiumRotation
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/device/rotation
|
||||
```
|
||||
|
||||
Retrieves the current spatial orientation of the device under test.
|
||||
|
||||
#### Response
|
||||
|
||||
`Rotation` - an object with the following properties:
|
||||
|
||||
| Name | Description | Type |
|
||||
| ---- | ---------------------------------------------------- | ------ |
|
||||
| `x` | Degrees by which the device is rotated on its X axis | number |
|
||||
| `y` | Degrees by which the device is rotated on its Y axis | number |
|
||||
| `z` | Degrees by which the device is rotated on its Z axis | number |
|
||||
|
||||
### setAppiumRotation
|
||||
|
||||
```
|
||||
POST /session/:sessionId/appium/device/rotation
|
||||
```
|
||||
|
||||
Sets the spatial orientation of the device under test.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
| ---- | ---------------------------------------------------- | ------ |
|
||||
| `x` | Degrees by which the device is rotated on its X axis | number |
|
||||
| `y` | Degrees by which the device is rotated on its Y axis | number |
|
||||
| `z` | Degrees by which the device is rotated on its Z axis | number |
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### getAppiumOrientation
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/device/orientation
|
||||
```
|
||||
|
||||
Retrieves the current orientation of the device under test.
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - either `PORTRAIT` or `LANDSCAPE`
|
||||
|
||||
### setAppiumOrientation
|
||||
|
||||
```
|
||||
POST /session/:sessionId/appium/device/orientation
|
||||
```
|
||||
|
||||
Sets the orientation of the device under test.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| `orientation` | New device orientation. Supported values are `PORTRAIT` or `LANDSCAPE`. | string |
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
@@ -140,6 +140,10 @@ GET /session/:sessionId/orientation
|
||||
|
||||
Retrieves the current orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getAppiumOrientation`](./appium.md#getappiumorientation) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - either `PORTRAIT` or `LANDSCAPE`
|
||||
@@ -154,6 +158,10 @@ POST /session/:sessionId/orientation
|
||||
|
||||
Sets the orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`setAppiumOrientation`](./appium.md#setappiumorientation) instead
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
|
||||
@@ -21,6 +21,10 @@ GET /session/:sessionId/rotation
|
||||
|
||||
Retrieves the current spatial orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getAppiumRotation`](./appium.md#getappiumrotation) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`Rotation` - an object with the following properties:
|
||||
@@ -41,6 +45,10 @@ POST /session/:sessionId/rotation
|
||||
|
||||
Sets the spatial orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`setAppiumRotation`](./appium.md#setappiumrotation) instead
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
@@ -63,6 +71,10 @@ GET /session/:sessionId/context
|
||||
|
||||
Retrieves the active application context.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getCurrentAppiumContext`](./appium.md#getcurrentappiumcontext) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - the name of the active context
|
||||
@@ -77,6 +89,10 @@ POST /session/:sessionId/context
|
||||
|
||||
Sets an application context as the active context.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`setAppiumContext`](./appium.md#setappiumcontext) instead
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
@@ -97,6 +113,10 @@ GET /session/:sessionId/contexts
|
||||
|
||||
Retrieves all available application contexts.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getAppiumContexts`](./appium.md#getappiumcontexts) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`string[]` - the names of available contexts
|
||||
|
||||
@@ -170,10 +170,17 @@ Modifies the [`createSession`](./webdriver.md#createsession) endpoint:
|
||||
All endpoints for this plugin can be invoked without creating a session, allowing you to prepare
|
||||
your test environment in advance.
|
||||
|
||||
!!! info
|
||||
|
||||
Prior to plugin version 1.2.0, all endpoints below were mounted under the `/storage` prefix
|
||||
(e.g. `/storage/add`) instead of `/appium/storage`. The `/storage`-prefixed routes are still
|
||||
available for backward compatibility, but are deprecated and will be removed in a future
|
||||
version of the plugin.
|
||||
|
||||
### addStorageItem
|
||||
|
||||
```
|
||||
POST /storage/add
|
||||
POST /appium/storage/add
|
||||
```
|
||||
|
||||
Adds a new file to the storage.
|
||||
@@ -200,8 +207,8 @@ Example:
|
||||
```json
|
||||
{
|
||||
"ws": {
|
||||
"stream": "/storage/add/ccc963411b2621335657963322890305ebe96186/stream",
|
||||
"events": "/storage/add/ccc963411b2621335657963322890305ebe96186/events"
|
||||
"stream": "/appium/storage/add/ccc963411b2621335657963322890305ebe96186/stream",
|
||||
"events": "/appium/storage/add/ccc963411b2621335657963322890305ebe96186/events"
|
||||
},
|
||||
"ttlMs": 300000
|
||||
}
|
||||
@@ -210,7 +217,7 @@ Example:
|
||||
### deleteStorageItem
|
||||
|
||||
```
|
||||
POST /storage/delete
|
||||
POST /appium/storage/delete
|
||||
```
|
||||
|
||||
Deletes a file in the storage.
|
||||
@@ -229,7 +236,7 @@ the storage, or if the delete request is invalid.
|
||||
### listStorageItems
|
||||
|
||||
```
|
||||
GET /storage/list
|
||||
GET /appium/storage/list
|
||||
```
|
||||
|
||||
List all files present in the storage.
|
||||
@@ -247,7 +254,7 @@ List all files present in the storage.
|
||||
### resetStorage
|
||||
|
||||
```
|
||||
POST /storage/reset
|
||||
POST /appium/storage/reset
|
||||
```
|
||||
|
||||
Deletes all uploaded files and stops any incomplete uploads.
|
||||
|
||||
@@ -159,14 +159,14 @@ extensions include scripts.
|
||||
#### Usage
|
||||
|
||||
```
|
||||
appium {driver|plugin} run <extension-name> <script-name> [script-args]
|
||||
appium {driver|plugin} run <extension-name> [<script-name> [<script-args>]]
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
| ---------------- | --------------------------------------------- |
|
||||
| `extension-name` | The short name of the installed extension |
|
||||
| `script-name` | The name of the script to be run |
|
||||
| `script-args` | Any additional arguments passed to the script |
|
||||
| Argument | Description |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `extension-name` | The short 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 |
|
||||
|
||||
#### Options
|
||||
|
||||
@@ -182,6 +182,12 @@ appium {driver|plugin} run <extension-name> <script-name> [script-args]
|
||||
appium driver run uiautomator2 reset
|
||||
```
|
||||
|
||||
- List all available scripts included in the XCUITest driver:
|
||||
|
||||
```
|
||||
appium driver run xcuitest
|
||||
```
|
||||
|
||||
## `update`
|
||||
|
||||
Updates one or more extensions. Only supported for extensions installed via `npm`. By default,
|
||||
|
||||
@@ -65,7 +65,7 @@ appium
|
||||
| `--tmp` | Absolute path to the directory used for temporary files | string | [`os.tmpdir()`](https://nodejs.org/api/os.html#ostmpdir) |
|
||||
| `--use-drivers` | List of drivers to activate. By default, all installed drivers are activated. | array<string> | `[]` |
|
||||
| `--use-plugins` | List of plugins to activate. By default, no plugins are activated. Set to `["all"]` to activate all installed plugins. | array<string> | `[]` |
|
||||
| `--webhook`, `-G` | URL for an HTTP listener where the server logs should be output. This does not affect output on the console. | string | |
|
||||
| `--webhook`, `-G` | URL for an HTTP listener where the server logs should be output. This does not affect output on the console. A bare `host:port` is also accepted, which posts the logs to the root path over plain http; a value having neither a scheme nor a port falls back to `127.0.0.1:9003`. | string | |
|
||||
|
||||
### Info Options
|
||||
|
||||
|
||||
@@ -10,19 +10,16 @@
|
||||
/* eslint-disable promise/prefer-await-to-callbacks */
|
||||
/* eslint-disable promise/prefer-await-to-then */
|
||||
|
||||
const {deploy, buildSite} = require('@appium/docutils');
|
||||
const {
|
||||
log,
|
||||
LANGS,
|
||||
DOCS_DIR,
|
||||
DOCS_BRANCH,
|
||||
DOCS_PREFIX,
|
||||
DOCS_REMOTE,
|
||||
LATEST_ALIAS,
|
||||
} = require('./utils');
|
||||
const path = require('node:path');
|
||||
const semver = require('semver');
|
||||
const {version} = require('../../package.json');
|
||||
import {readFileSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
import {deploy, buildSite} from '@appium/docutils';
|
||||
import semver from 'semver';
|
||||
|
||||
import {log, LANGS, DOCS_DIR, DOCS_BRANCH, DOCS_PREFIX, DOCS_REMOTE, LATEST_ALIAS} from './utils.js';
|
||||
|
||||
const {version} = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
||||
|
||||
const branch = process.env.APPIUM_DOCS_BRANCH ?? DOCS_BRANCH;
|
||||
const basePrefix = process.env.APPIUM_DOCS_PREFIX ?? DOCS_PREFIX;
|
||||
@@ -67,10 +64,10 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
main().catch((err) => {
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = main;
|
||||
export default main;
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
/* eslint-disable promise/prefer-await-to-callbacks */
|
||||
/* eslint-disable promise/prefer-await-to-then */
|
||||
|
||||
const path = require('node:path');
|
||||
const {fs} = require('@appium/support');
|
||||
const {AppiumConfigJsonSchema} = require('@appium/schema');
|
||||
import {realpathSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const DOC_PATH = path.resolve(__dirname, '..', 'en', 'cli', 'args.md');
|
||||
import {AppiumConfigJsonSchema} from '@appium/schema';
|
||||
import {fs} from '@appium/support';
|
||||
|
||||
const DOC_PATH = path.resolve(import.meta.dirname, '..', 'en', 'cli', 'args.md');
|
||||
|
||||
const START_TAG = `<!-- AUTOGEN-START -->`;
|
||||
const STOP_TAG = `<!-- AUTOGEN-STOP -->`;
|
||||
@@ -89,10 +92,10 @@ async function writeNewDoc(preStart, argTable, postEnd) {
|
||||
await fs.writeFile(DOC_PATH, newContents);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
|
||||
main().catch((err) => {
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = main;
|
||||
export default main;
|
||||
|
||||
@@ -2,28 +2,19 @@
|
||||
|
||||
// for simplicity this file is not transpiled and is run directly via an npm script
|
||||
//
|
||||
const {logger} = require('@appium/support');
|
||||
const path = require('node:path');
|
||||
import path from 'node:path';
|
||||
|
||||
import {logger} from '@appium/support';
|
||||
|
||||
const log = logger.getLogger('Docs');
|
||||
|
||||
const DOCS_REMOTE = 'origin';
|
||||
const DOCS_BRANCH = 'gh-pages';
|
||||
const DOCS_PREFIX = 'docs';
|
||||
const DOCS_DIR = path.resolve(__dirname, '..');
|
||||
const DOCS_DIR = path.resolve(import.meta.dirname, '..');
|
||||
const LATEST_ALIAS = 'latest';
|
||||
const ASSETS_DIR = path.join(DOCS_DIR, 'assets');
|
||||
const LANGS = ['en', 'ja', 'zh'];
|
||||
const DEFAULT_LANG = 'en';
|
||||
|
||||
module.exports = {
|
||||
log,
|
||||
DOCS_DIR,
|
||||
ASSETS_DIR,
|
||||
LANGS,
|
||||
DEFAULT_LANG,
|
||||
DOCS_BRANCH,
|
||||
DOCS_PREFIX,
|
||||
DOCS_REMOTE,
|
||||
LATEST_ALIAS,
|
||||
};
|
||||
export {log, DOCS_DIR, ASSETS_DIR, LANGS, DEFAULT_LANG, DOCS_BRANCH, DOCS_PREFIX, DOCS_REMOTE, LATEST_ALIAS};
|
||||
|
||||
@@ -17,3 +17,8 @@ authors:
|
||||
avatar: 'https://avatars.githubusercontent.com/u/4045041?v=4'
|
||||
slug: 'sai'
|
||||
url: 'https://saikrishna.tech'
|
||||
mykola:
|
||||
name: 'Mykola Mokhnach'
|
||||
description: 'Appium Core Maintainer'
|
||||
avatar: https://avatars.githubusercontent.com/u/7767781?v=4
|
||||
slug: 'mykola'
|
||||
|
||||
@@ -8,11 +8,13 @@ date: 2025-11-25
|
||||
# Announcing LambdaTest as Appium's Newest Development Partner
|
||||
|
||||
As part of our ongoing effort to recognize organizations that meaningfully support the Appium project through dedicated maintainer time, we are excited to welcome our newest Development Partner: LambdaTest!
|
||||
LambdaTest has long been an active participant in the automated testing ecosystem. Today, they are officially formalizing their commitment by investing people—dedicated engineers who will directly support Appium's development, maintenance, and ecosystem growth.
|
||||
This kind of hands-on contribution is crucial for ensuring Appium continues to evolve. We deeply value partners who empower their engineering teams to contribute code directly to open source rather than just sponsorship.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
LambdaTest has long been an active participant in the automated testing ecosystem. Today, they are officially formalizing their commitment by investing people—dedicated engineers who will directly support Appium's development, maintenance, and ecosystem growth.
|
||||
|
||||
This kind of hands-on contribution is crucial for ensuring Appium continues to evolve. We deeply value partners who empower their engineering teams to contribute code directly to open source rather than just sponsorship.
|
||||
|
||||
<div style="text-align:center;width:100%"><a href="https://lambdatest.com/?utm_source=appium.io&utm_medium=organic&utm_campaign=june_25&utm_term=sk&utm_content=webpage">
|
||||
<img src="/docs/en/latest/assets/images/sponsor-logo-lambdatest-dark.png#only-dark" width="300" />
|
||||
<img src="/docs/en/latest/assets/images/sponsor-logo-lambdatest-light.png#only-light" width="300" />
|
||||
@@ -20,15 +22,19 @@ This kind of hands-on contribution is crucial for ensuring Appium continues to e
|
||||
</div>
|
||||
|
||||
LambdaTest engineers are already contributing actively across several key Appium repositories. Their current focus includes:
|
||||
**appium-mcp** – Supporting Appium's implementation of the Model Context Protocol (MCP) to drive forward modern AI-assisted capabilities.
|
||||
**appium-remote-xpc** – Contributing to the native macOS/XPC-based remote execution layer, essential for iOS automation.
|
||||
**appium-tun-tap** – Assisting in the development and maintenance of network virtualization and tunneling components for advanced testing scenarios.
|
||||
|
||||
- [`appium-mcp`](https://github.com/appium/appium-mcp) – Supporting Appium's implementation of the Model Context Protocol (MCP) to drive forward modern AI-assisted capabilities.
|
||||
- [`appium-ios-remotexpc`](https://github.com/appium/appium-ios-remotexpc) – Contributing to the native macOS/XPC-based remote execution layer, essential for iOS automation.
|
||||
- [`appium-ios-tuntap`](https://github.com/appium/appium-ios-tuntap) – Assisting in the development and maintenance of network virtualization and tunneling components for advanced testing scenarios.
|
||||
|
||||
These contributions strengthen critical infrastructure within the Appium ecosystem and demonstrate LambdaTest's commitment to deep technical involvement in the project's evolution.
|
||||
About LambdaTest
|
||||
|
||||
**About LambdaTest**
|
||||
|
||||
LambdaTest’s platform already serves a massive community of Appium users. As a GenAI-powered Quality Engineering Platform, they provide a full-stack testing cloud with 10,000+ real mobile devices and 3,000+ browsers.
|
||||
|
||||
Beyond standard infrastructure, they are pushing the boundaries of test execution with AI-native test management and next-generation agents like KaneAI. By supporting Appium alongside Selenium, Playwright, and other major frameworks, LambdaTest helps teams orchestrate intelligent automation at cloud scale.
|
||||
|
||||
LambdaTest will continue contributing people, code, and development expertise to strengthen Appium's core functionality. Their commitment represents a significant investment into the future of the framework, and we're thrilled to have them collaborating with us at this deeper level.
|
||||
|
||||
The Appium project is proud to recognize LambdaTest as our newest official Development Partner. We can't wait to see what we'll build together!
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
authors:
|
||||
- mykola
|
||||
date: 2026-09-01
|
||||
---
|
||||
|
||||
# Announcing watchOS Simulator Support in the XCUITest Driver
|
||||
|
||||
We’re excited to announce that the XCUITest driver now supports automation of watchOS apps — a
|
||||
platform that’s been on Appium’s requested list for a long time.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
In order to automate watchOS, you'll need the following:
|
||||
|
||||
- XCUITest driver version 12.6.0 or later (WebDriverAgent 16.5.0 or later)
|
||||
- Xcode 15.4 or later
|
||||
- watchOS 10 or later
|
||||
|
||||
Important to note: ^^the driver currently only works with simulators — real devices are not
|
||||
compatible.^^
|
||||
|
||||
The good news: automating a watchOS app looks just like automating any other iOS/iPadOS app. The
|
||||
standard use-cases of finding and clicking on elements, retrieving the page source, using the
|
||||
Appium Inspector — all work as usual. The one exception here is standard W3C gesture/touch actions
|
||||
(tap/swipe via the Actions API), which are not supported.
|
||||
|
||||
On top of that, a few watchOS-specific extensions are available:
|
||||
|
||||
- `mobile: pressButton` — press the Digital Crown or Action button
|
||||
- `mobile: rotateDigitalCrown` — rotate the Digital Crown (since driver version 12.7.0)
|
||||
- `mobile: performHandGesture` — perform double-tap or wrist flick hand gestures (since driver
|
||||
version 12.7.0)
|
||||
|
||||
For full setup instructions, capabilities, and limitations, check out the
|
||||
[watchOS guide](https://appium.github.io/appium-xcuitest-driver/latest/guides/watchos/).
|
||||
|
||||
Happy testing!
|
||||
@@ -222,17 +222,24 @@ So you'll probably end up overriding `createSession`. You can do so by defining
|
||||
driver:
|
||||
|
||||
```js
|
||||
async createSession(jwpCaps, reqCaps, w3cCaps, otherDriverData) {
|
||||
const [sessionId, caps] = super.createSession(w3cCaps);
|
||||
async createSession(w3cCaps) {
|
||||
const [sessionId, caps] = await super.createSession(w3cCaps);
|
||||
// do your own stuff here
|
||||
return [sessionId, caps];
|
||||
}
|
||||
```
|
||||
|
||||
For legacy reasons, your function will receive old-style JSON Wire Protocol desired and required
|
||||
caps as the first two arguments. Given that the old protocol isn't supported anymore and clients
|
||||
have all been updated, you can instead only rely on the `w3cCaps` parameter. (For a discussion
|
||||
about what `otherDriverData` is about, see the section below on concurrent drivers).
|
||||
!!! warning "Deprecated"
|
||||
|
||||
```
|
||||
Older drivers may still define `createSession` with up to four parameters, e.g.
|
||||
`createSession(jwpCaps, reqCaps, w3cCaps, otherDriverData)`. This shape is a holdover from the
|
||||
retired JSON Wire Protocol, which required desired and required caps as the first two
|
||||
arguments; only the first W3C-shaped argument was ever used. The `otherDriverData` parameter is
|
||||
likewise deprecated in favor of [IPC](#send-messages-to-plugins-running-on-the-same-session) for
|
||||
coordinating with other concurrently-running sessions. New drivers should use the single-argument
|
||||
form shown above.
|
||||
```
|
||||
|
||||
You'll want to make sure to call `super.createSession` in order to get the session ID as well as
|
||||
the processed capabilities (note that capabilities are also set on `this.caps`; modifying `caps`
|
||||
@@ -522,14 +529,13 @@ route, then the route will not be proxied and instead will be handled by your dr
|
||||
example, we are avoiding proxying all `POST` routes that have the `appium` prefix.
|
||||
|
||||
Next, we have to set up the proxying itself. The way to do this is to use a special class from
|
||||
Appium called `JWProxy`. (The name means "JSON Wire Proxy" and is related to a legacy
|
||||
implementation of the protocol). You'll want to create a `JWProxy` object using the details required to
|
||||
connect to the remote server:
|
||||
Appium called `WebDriverProxy`. You'll want to create a `WebDriverProxy` object using the details
|
||||
required to connect to the remote server:
|
||||
|
||||
```js
|
||||
// import {JWProxy} from 'appium/driver';
|
||||
// import {WebDriverProxy} from 'appium/driver';
|
||||
|
||||
const proxy = new JWProxy({
|
||||
const proxy = new WebDriverProxy({
|
||||
server: 'remote.server',
|
||||
port: 1234,
|
||||
base: '/',
|
||||
@@ -541,8 +547,8 @@ this.proxyCommand = proxy.command.bind(proxy);
|
||||
|
||||
Here we are creating a proxy object and assigning some of its methods to `this` under the names
|
||||
`proxyReqRes` and `proxyCommand`. This is required for Appium to use the proxy, so don't forget
|
||||
this step! The `JWProxy` has a variety of other options which you can check out in the source code,
|
||||
as well. (TODO: publish options as API docs and link here).
|
||||
this step! The `WebDriverProxy` has a variety of other options which you can check out in the
|
||||
source code as well.
|
||||
|
||||
Finally, we need a way to tell Appium when the proxy is active. For your driver it might always
|
||||
be active, or it might only be active when in a certain context. You can define the logic as an
|
||||
@@ -799,6 +805,14 @@ that multiple simultaneous sessions don't use the same resources:
|
||||
3. Have each driver express what resources it is using, then examine currently-used resources from
|
||||
other drivers when a new session begins.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
```
|
||||
The `driverData`-based mechanism described below is deprecated. Prefer
|
||||
[IPC](#send-messages-to-plugins-running-on-the-same-session) for coordinating resources across
|
||||
concurrently-running sessions instead.
|
||||
```
|
||||
|
||||
To support this third strategy, you can implement `get driverData` in your driver to return what
|
||||
sorts of resources your driver is currently using, for example:
|
||||
|
||||
@@ -810,7 +824,7 @@ get driverData() {
|
||||
|
||||
Now, when a new session is started on your driver, the `driverData` response from any other
|
||||
simultaneously running drivers (of the same type) will also be included, as the last parameter of
|
||||
the `createSession` method:
|
||||
the deprecated, multi-argument form of the `createSession` method:
|
||||
|
||||
```js
|
||||
async createSession(jwpCaps, reqCaps, w3cCaps, driverData)
|
||||
|
||||
@@ -483,9 +483,9 @@ 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)
|
||||
in a variety of ways). But, for example, you could add new Express middleware 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:
|
||||
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:
|
||||
|
||||
- `expressApp`: the Express app object
|
||||
- `httpServer`: the Node HTTP server object
|
||||
@@ -498,6 +498,25 @@ results you'll need to test! Warning: this should be considered an advanced feat
|
||||
knowledge of Express, as well as the care not to do anything that could affect the operation of
|
||||
other parts of the Appium server!
|
||||
|
||||
`updateServer` runs _after_ Appium's own routes are registered, so `expressApp.use(...)` here
|
||||
won't see requests to paths Appium already owns — Express matches middleware and routes in
|
||||
registration order. For middleware that must see requests to those routes too (e.g. logging or
|
||||
auth), use `httpServer.frontRouter` instead: Appium mounts this
|
||||
[Router](https://expressjs.com/en/5x/api/router/) before any route is registered, so anything
|
||||
attached to it — even from inside `updateServer` — still runs ahead of route matching. Note this
|
||||
is still _after_ Appium's own baseline middleware (CORS, WebSocket upgrade handling, body
|
||||
parsing, etc.), so it won't see requests Appium itself has already rejected, e.g. a CORS
|
||||
preflight failure or a body that failed to parse:
|
||||
|
||||
```js
|
||||
static async updateServer(expressApp, httpServer, cliArgs) {
|
||||
httpServer.frontRouter.use((req, res, next) => {
|
||||
console.log(`Incoming request: ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Handle unexpected session shutdown
|
||||
|
||||
When developing a plugin you may want to add some cleanup logic for when a session ends. You would
|
||||
|
||||
@@ -130,7 +130,7 @@ appium driver install --source=npm appium-flutter-integration-driver
|
||||
appium driver install --source=npm appium-lg-webos-driver
|
||||
```
|
||||
|
||||
### [Linux](https://github.com/fantonglang/appium-linux-driver)
|
||||
### [Linux (@fantonglang)](https://github.com/fantonglang/appium-linux-driver)
|
||||
|
||||
!!! warning
|
||||
|
||||
@@ -149,6 +149,16 @@ yarn install
|
||||
node ./
|
||||
```
|
||||
|
||||
### [Linux (@Itsmeaj)](https://github.com/Itsmeaj/appium)
|
||||
|
||||
- Target: Linux desktop applications on X11 and Wayland
|
||||
- Mode: Native
|
||||
- Supported by: `@Itsmeaj`
|
||||
|
||||
```sh title="Install This Driver"
|
||||
appium driver install --source=npm @itsmeaj/appium-linux-driver
|
||||
```
|
||||
|
||||
### [NovaWindows](https://github.com/AutomateThePlanet/appium-novawindows-driver)
|
||||
|
||||
!!! info
|
||||
|
||||
@@ -107,6 +107,48 @@ Retrieves the [execute methods](../../guides/execute-methods.md) supported in th
|
||||
origin (driver-specific or plugin-specific). Refer to [the type definition file](https://github.com/appium/appium/blob/master/packages/types/lib/command.ts)
|
||||
for a detailed structure of this object.
|
||||
|
||||
### getCurrentAppiumContext
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/context
|
||||
```
|
||||
|
||||
Retrieves the active application context.
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - the name of the active context
|
||||
|
||||
### setAppiumContext
|
||||
|
||||
```
|
||||
POST /session/:sessionId/appium/context
|
||||
```
|
||||
|
||||
Sets an application context as the active context.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
| ------ | -------------------------------------------- | ------ |
|
||||
| `name` | Name of the context to set as the active one | string |
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### getAppiumContexts
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/contexts
|
||||
```
|
||||
|
||||
Retrieves all available application contexts.
|
||||
|
||||
#### Response
|
||||
|
||||
`string[]` - the names of available contexts
|
||||
|
||||
### getLogEvents
|
||||
|
||||
```
|
||||
@@ -393,3 +435,71 @@ Retrieves data from a directory on the device under test.
|
||||
#### Response
|
||||
|
||||
`string` - the Base64-encoded zip file of the directory contents
|
||||
|
||||
### getAppiumRotation
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/device/rotation
|
||||
```
|
||||
|
||||
Retrieves the current spatial orientation of the device under test.
|
||||
|
||||
#### Response
|
||||
|
||||
`Rotation` - an object with the following properties:
|
||||
|
||||
| Name | Description | Type |
|
||||
| ---- | ---------------------------------------------------- | ------ |
|
||||
| `x` | Degrees by which the device is rotated on its X axis | number |
|
||||
| `y` | Degrees by which the device is rotated on its Y axis | number |
|
||||
| `z` | Degrees by which the device is rotated on its Z axis | number |
|
||||
|
||||
### setAppiumRotation
|
||||
|
||||
```
|
||||
POST /session/:sessionId/appium/device/rotation
|
||||
```
|
||||
|
||||
Sets the spatial orientation of the device under test.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
| ---- | ---------------------------------------------------- | ------ |
|
||||
| `x` | Degrees by which the device is rotated on its X axis | number |
|
||||
| `y` | Degrees by which the device is rotated on its Y axis | number |
|
||||
| `z` | Degrees by which the device is rotated on its Z axis | number |
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### getAppiumOrientation
|
||||
|
||||
```
|
||||
GET /session/:sessionId/appium/device/orientation
|
||||
```
|
||||
|
||||
Retrieves the current orientation of the device under test.
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - either `PORTRAIT` or `LANDSCAPE`
|
||||
|
||||
### setAppiumOrientation
|
||||
|
||||
```
|
||||
POST /session/:sessionId/appium/device/orientation
|
||||
```
|
||||
|
||||
Sets the orientation of the device under test.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| `orientation` | New device orientation. Supported values are `PORTRAIT` or `LANDSCAPE`. | string |
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
@@ -140,6 +140,10 @@ GET /session/:sessionId/orientation
|
||||
|
||||
Retrieves the current orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getAppiumOrientation`](./appium.md#getappiumorientation) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - either `PORTRAIT` or `LANDSCAPE`
|
||||
@@ -154,6 +158,10 @@ POST /session/:sessionId/orientation
|
||||
|
||||
Sets the orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`setAppiumOrientation`](./appium.md#setappiumorientation) instead
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
|
||||
@@ -21,6 +21,10 @@ GET /session/:sessionId/rotation
|
||||
|
||||
Retrieves the current spatial orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getAppiumRotation`](./appium.md#getappiumrotation) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`Rotation` - an object with the following properties:
|
||||
@@ -41,6 +45,10 @@ POST /session/:sessionId/rotation
|
||||
|
||||
Sets the spatial orientation of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`setAppiumRotation`](./appium.md#setappiumrotation) instead
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
@@ -63,6 +71,10 @@ GET /session/:sessionId/context
|
||||
|
||||
Retrieves the active application context.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getCurrentAppiumContext`](./appium.md#getcurrentappiumcontext) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - the name of the active context
|
||||
@@ -77,6 +89,10 @@ POST /session/:sessionId/context
|
||||
|
||||
Sets an application context as the active context.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`setAppiumContext`](./appium.md#setappiumcontext) instead
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Description | Type |
|
||||
@@ -97,6 +113,10 @@ GET /session/:sessionId/contexts
|
||||
|
||||
Retrieves all available application contexts.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use [`getAppiumContexts`](./appium.md#getappiumcontexts) instead
|
||||
|
||||
#### Response
|
||||
|
||||
`string[]` - the names of available contexts
|
||||
|
||||
@@ -170,10 +170,17 @@ Modifies the [`createSession`](./webdriver.md#createsession) endpoint:
|
||||
All endpoints for this plugin can be invoked without creating a session, allowing you to prepare
|
||||
your test environment in advance.
|
||||
|
||||
!!! info
|
||||
|
||||
Prior to plugin version 1.2.0, all endpoints below were mounted under the `/storage` prefix
|
||||
(e.g. `/storage/add`) instead of `/appium/storage`. The `/storage`-prefixed routes are still
|
||||
available for backward compatibility, but are deprecated and will be removed in a future
|
||||
version of the plugin.
|
||||
|
||||
### addStorageItem
|
||||
|
||||
```
|
||||
POST /storage/add
|
||||
POST /appium/storage/add
|
||||
```
|
||||
|
||||
Adds a new file to the storage.
|
||||
@@ -200,8 +207,8 @@ Example:
|
||||
```json
|
||||
{
|
||||
"ws": {
|
||||
"stream": "/storage/add/ccc963411b2621335657963322890305ebe96186/stream",
|
||||
"events": "/storage/add/ccc963411b2621335657963322890305ebe96186/events"
|
||||
"stream": "/appium/storage/add/ccc963411b2621335657963322890305ebe96186/stream",
|
||||
"events": "/appium/storage/add/ccc963411b2621335657963322890305ebe96186/events"
|
||||
},
|
||||
"ttlMs": 300000
|
||||
}
|
||||
@@ -210,7 +217,7 @@ Example:
|
||||
### deleteStorageItem
|
||||
|
||||
```
|
||||
POST /storage/delete
|
||||
POST /appium/storage/delete
|
||||
```
|
||||
|
||||
Deletes a file in the storage.
|
||||
@@ -229,7 +236,7 @@ the storage, or if the delete request is invalid.
|
||||
### listStorageItems
|
||||
|
||||
```
|
||||
GET /storage/list
|
||||
GET /appium/storage/list
|
||||
```
|
||||
|
||||
List all files present in the storage.
|
||||
@@ -247,7 +254,7 @@ List all files present in the storage.
|
||||
### resetStorage
|
||||
|
||||
```
|
||||
POST /storage/reset
|
||||
POST /appium/storage/reset
|
||||
```
|
||||
|
||||
Deletes all uploaded files and stops any incomplete uploads.
|
||||
|
||||
@@ -159,14 +159,14 @@ extensions include scripts.
|
||||
#### Usage
|
||||
|
||||
```
|
||||
appium {driver|plugin} run <extension-name> <script-name> [script-args]
|
||||
appium {driver|plugin} run <extension-name> [<script-name> [<script-args>]]
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
| ---------------- | --------------------------------------------- |
|
||||
| `extension-name` | The short name of the installed extension |
|
||||
| `script-name` | The name of the script to be run |
|
||||
| `script-args` | Any additional arguments passed to the script |
|
||||
| Argument | Description |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `extension-name` | The short 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 |
|
||||
|
||||
#### Options
|
||||
|
||||
@@ -182,6 +182,12 @@ appium {driver|plugin} run <extension-name> <script-name> [script-args]
|
||||
appium driver run uiautomator2 reset
|
||||
```
|
||||
|
||||
- List all available scripts included in the XCUITest driver:
|
||||
|
||||
```
|
||||
appium driver run xcuitest
|
||||
```
|
||||
|
||||
## `update`
|
||||
|
||||
Updates one or more extensions. Only supported for extensions installed via `npm`. By default,
|
||||
|
||||
@@ -65,7 +65,7 @@ appium
|
||||
| `--tmp` | Absolute path to the directory used for temporary files | string | [`os.tmpdir()`](https://nodejs.org/api/os.html#ostmpdir) |
|
||||
| `--use-drivers` | List of drivers to activate. By default, all installed drivers are activated. | array<string> | `[]` |
|
||||
| `--use-plugins` | List of plugins to activate. By default, no plugins are activated. Set to `["all"]` to activate all installed plugins. | array<string> | `[]` |
|
||||
| `--webhook`, `-G` | URL for an HTTP listener where the server logs should be output. This does not affect output on the console. | string | |
|
||||
| `--webhook`, `-G` | URL for an HTTP listener where the server logs should be output. This does not affect output on the console. A bare `host:port` is also accepted, which posts the logs to the root path over plain http; a value having neither a scheme nor a port falls back to `127.0.0.1:9003`. | string | |
|
||||
|
||||
### Info Options
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
@@ -7,8 +5,6 @@
|
||||
*
|
||||
* @see https://npm.im/@appium/base-driver
|
||||
* @example
|
||||
* const { BaseDriver, errors } = require('appium/driver');
|
||||
* import { BaseDriver, errors } from 'appium/driver.js';
|
||||
*/
|
||||
|
||||
/** @type {import('@appium/base-driver')} */
|
||||
module.exports = require('@appium/base-driver');
|
||||
export * from '@appium/base-driver';
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const appium = require('./build/lib/main.js');
|
||||
import {realpathSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
if (require.main === module) {
|
||||
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])) {
|
||||
appium.main();
|
||||
}
|
||||
|
||||
module.exports = appium;
|
||||
export * from './build/lib/main.js';
|
||||
|
||||
+50
-101
@@ -4,7 +4,6 @@ import {
|
||||
CREATE_SESSION_COMMAND,
|
||||
DELETE_SESSION_COMMAND,
|
||||
DriverCore,
|
||||
errors,
|
||||
type ExtensionCore,
|
||||
generateDriverLogPrefix,
|
||||
GET_STATUS_COMMAND,
|
||||
@@ -20,7 +19,6 @@ import {util} from '@appium/support';
|
||||
import type {
|
||||
AppiumServer,
|
||||
DriverCaps,
|
||||
DriverData,
|
||||
DriverOpts,
|
||||
ExternalDriver,
|
||||
IAppiumIpc,
|
||||
@@ -35,15 +33,20 @@ import type {
|
||||
} from '@appium/types';
|
||||
import type WebSocket from 'ws';
|
||||
|
||||
import * as bidiCommands from './bidi-commands';
|
||||
import {BIDI_BASE_PATH, DRIVER_TYPE, SESSION_DISCOVERY_FEATURE} from './constants';
|
||||
import type {DriverConfig} from './extension/driver-config';
|
||||
import {APPIUM_VER, getBuildInfo, updateBuildInfo} from './helpers/build';
|
||||
import {makeNonW3cCapsError, parseCapsForInnerDriver, type ParsedDriverCaps, pullSettings} from './helpers/capability';
|
||||
import * as insecureFeatures from './insecure-features';
|
||||
import * as inspectorCommands from './inspector-commands';
|
||||
import {getDefaultsForExtension} from './schema';
|
||||
import {compact, pickBy, pull} from './utils';
|
||||
import * as bidiCommands from './bidi-commands.js';
|
||||
import {BIDI_BASE_PATH, DRIVER_TYPE, SESSION_DISCOVERY_FEATURE} from './constants.js';
|
||||
import type {DriverConfig} from './extension/driver-config.js';
|
||||
import {APPIUM_VER, getBuildInfo, updateBuildInfo} from './helpers/build.js';
|
||||
import {
|
||||
makeNonW3cCapsError,
|
||||
parseCapsForInnerDriver,
|
||||
type ParsedDriverCaps,
|
||||
pullSettings,
|
||||
} from './helpers/capability.js';
|
||||
import * as insecureFeatures from './insecure-features.js';
|
||||
import * as inspectorCommands from './inspector-commands.js';
|
||||
import {getDefaultsForExtension} from './schema/index.js';
|
||||
import {pickBy} from './utils/index.js';
|
||||
|
||||
const desiredCapabilityConstraints = {
|
||||
automationName: {
|
||||
@@ -59,6 +62,14 @@ const desiredCapabilityConstraints = {
|
||||
export type AppiumDriverConstraints = typeof desiredCapabilityConstraints;
|
||||
export type W3CAppiumDriverCaps = W3CDriverCaps<AppiumDriverConstraints>;
|
||||
|
||||
/**
|
||||
* {@link DriverCaps} with `webSocketUrl` widened to `string`: the client sends it as a boolean,
|
||||
* but the inner driver's response carries the resolved BiDi URL as a string.
|
||||
*/
|
||||
type DriverCapsWithBidiUrl = Omit<DriverCaps<AppiumDriverConstraints>, 'webSocketUrl'> & {
|
||||
webSocketUrl?: string | boolean;
|
||||
};
|
||||
|
||||
/** Result shape for umbrella {@link AppiumDriver.createSession} / {@link AppiumDriver.deleteSession}. */
|
||||
interface SessionHandlerResult<V = unknown> {
|
||||
value?: V;
|
||||
@@ -66,15 +77,13 @@ interface SessionHandlerResult<V = unknown> {
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
type SessionHandlerCreateResult = SessionHandlerResult<
|
||||
[string, DriverCaps<AppiumDriverConstraints>, string | undefined]
|
||||
>;
|
||||
type SessionHandlerCreateResult = SessionHandlerResult<[string, DriverCapsWithBidiUrl, string | undefined]>;
|
||||
|
||||
type SessionHandlerDeleteResult = SessionHandlerResult<void>;
|
||||
|
||||
/** @internal Not part of {@link ExternalDriver}; used only when wiring session IPC. */
|
||||
type IpcAssignable = {
|
||||
assignIpc?: (ipc: IAppiumIpc) => Promise<void>;
|
||||
assignIpc: (ipc: IAppiumIpc) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -84,8 +93,6 @@ type IpcAssignable = {
|
||||
export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
readonly sessions: Record<string, ExternalDriver> = {};
|
||||
|
||||
readonly pendingDrivers: Record<string, ExternalDriver[]> = {};
|
||||
|
||||
/**
|
||||
* The umbrella driver does not observe its own command timeout; inner session drivers do.
|
||||
*/
|
||||
@@ -156,7 +163,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
}
|
||||
|
||||
/** The umbrella driver does not queue commands; inner session drivers may. */
|
||||
get isCommandsQueueEnabled(): boolean {
|
||||
override get isCommandsQueueEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -263,20 +270,17 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
|
||||
/**
|
||||
* Creates a session: picks an inner driver from caps, runs plugin hooks, and returns a protocol
|
||||
* envelope with either `[sessionId, caps, protocol]` or an error. Legacy call sites may pass the
|
||||
* same W3C caps in up to three positions; the first W3C-shaped value wins.
|
||||
* envelope with either `[sessionId, caps, protocol]` or an error.
|
||||
*
|
||||
* @param rawW3cCapabilities - the new session capabilities in W3C format
|
||||
*/
|
||||
async createSession(
|
||||
w3cCapabilities1: W3CAppiumDriverCaps,
|
||||
w3cCapabilities2?: W3CAppiumDriverCaps,
|
||||
w3cCapabilities3?: W3CAppiumDriverCaps,
|
||||
): Promise<SessionHandlerCreateResult> {
|
||||
async createSession(rawW3cCapabilities: W3CAppiumDriverCaps): Promise<SessionHandlerCreateResult> {
|
||||
const defaultCapabilities = structuredClone(this.args.defaultCapabilities);
|
||||
const defaultSettings = pullSettings((defaultCapabilities ?? {}) as StringRecord);
|
||||
const w3cCapabilities = structuredClone([w3cCapabilities3, w3cCapabilities2, w3cCapabilities1].find(isW3cCaps));
|
||||
if (!w3cCapabilities) {
|
||||
if (!isW3cCaps(rawW3cCapabilities)) {
|
||||
throw makeNonW3cCapsError();
|
||||
}
|
||||
const w3cCapabilities = structuredClone(rawW3cCapabilities);
|
||||
const w3cSettings = {
|
||||
...defaultSettings,
|
||||
...pullSettings(w3cCapabilities.alwaysMatch ?? {}),
|
||||
@@ -287,7 +291,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
|
||||
const protocol = PROTOCOLS.W3C;
|
||||
let innerSessionId: string;
|
||||
let dCaps: DriverCaps<AppiumDriverConstraints> & {webSocketUrl?: string | boolean};
|
||||
let dCaps: DriverCapsWithBidiUrl;
|
||||
try {
|
||||
// Parse the caps into a format that the InnerDriver will accept
|
||||
const parsedCaps = parseCapsForInnerDriver<AppiumDriverConstraints>(
|
||||
@@ -312,9 +316,6 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
await this.deleteAllSessions();
|
||||
}
|
||||
|
||||
let runningDriversData: DriverData[] = [];
|
||||
let otherPendingDriversData: DriverData[] = [];
|
||||
|
||||
const driverInstance = new InnerDriver(this.args, true) as unknown as ExternalDriver;
|
||||
|
||||
this.configureDriverFeatures(driverInstance, driverName);
|
||||
@@ -342,38 +343,19 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
driverInstance.serverPort = this.args.port;
|
||||
driverInstance.serverPath = this.args.basePath;
|
||||
|
||||
try {
|
||||
runningDriversData = (await this.curSessionDataForDriver(InnerDriver)) ?? [];
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new errors.SessionNotCreatedError(msg);
|
||||
}
|
||||
this.pendingDrivers[InnerDriver.name] = this.pendingDrivers[InnerDriver.name] || [];
|
||||
otherPendingDriversData = compact(this.pendingDrivers[InnerDriver.name].map((drv) => drv.driverData));
|
||||
this.pendingDrivers[InnerDriver.name].push(driverInstance);
|
||||
|
||||
try {
|
||||
[innerSessionId, dCaps] = (await driverInstance.createSession(
|
||||
processedW3CCapabilities as never,
|
||||
processedW3CCapabilities,
|
||||
processedW3CCapabilities,
|
||||
[...runningDriversData, ...otherPendingDriversData],
|
||||
)) as [string, DriverCaps<AppiumDriverConstraints> & {webSocketUrl?: string | boolean}];
|
||||
this.sessions[innerSessionId] = driverInstance;
|
||||
// create an IPC channel for the driver and all plugins on this session
|
||||
this.sessionIpcs[innerSessionId] = new AppiumIpc({
|
||||
maxObjSize: this.args.maxIpcDataSize,
|
||||
maxTopics: this.args.maxIpcTopics,
|
||||
log: driverInstance.log,
|
||||
});
|
||||
const extDriver = driverInstance as unknown as IpcAssignable;
|
||||
if (typeof extDriver.assignIpc === 'function') {
|
||||
// TODO remove this existence guard as a breaking change in Appium 3
|
||||
await extDriver.assignIpc(this.sessionIpcs[innerSessionId]);
|
||||
}
|
||||
} finally {
|
||||
pull(this.pendingDrivers[InnerDriver.name], driverInstance);
|
||||
}
|
||||
[innerSessionId, dCaps] = (await driverInstance.createSession(processedW3CCapabilities as never)) as [
|
||||
string,
|
||||
DriverCapsWithBidiUrl,
|
||||
];
|
||||
this.sessions[innerSessionId] = driverInstance;
|
||||
// create an IPC channel for the driver and all plugins on this session
|
||||
this.sessionIpcs[innerSessionId] = new AppiumIpc({
|
||||
maxObjSize: this.args.maxIpcDataSize,
|
||||
maxTopics: this.args.maxIpcTopics,
|
||||
log: driverInstance.log,
|
||||
});
|
||||
const extDriver = driverInstance as unknown as IpcAssignable;
|
||||
await extDriver.assignIpc(this.sessionIpcs[innerSessionId]);
|
||||
|
||||
this.attachUnexpectedShutdownHandler(driverInstance, innerSessionId);
|
||||
|
||||
@@ -405,9 +387,6 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
`Upstream driver responded with webSocketUrl ${dCaps.webSocketUrl}, will rewrite to ` +
|
||||
`${bidiUrl} for response to client`,
|
||||
);
|
||||
// @ts-ignore webSocketUrl gets sent by the client as a boolean, but then it is supposed
|
||||
// to come back from the server as a string. TODO figure out how to express this in our
|
||||
// capability constraint system
|
||||
dCaps.webSocketUrl = bidiUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -460,42 +439,15 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects `driverData` for every active session whose driver class matches `InnerDriver.name`
|
||||
* (used when creating another session of the same driver type).
|
||||
* @remarks `InnerDriver` is expected to be the driver class; only `.name` is read.
|
||||
*/
|
||||
async curSessionDataForDriver(InnerDriver: {name: string}): Promise<DriverData[]> {
|
||||
const data = compact(
|
||||
Object.values(this.sessions)
|
||||
.filter((s) => s.constructor.name === InnerDriver.name)
|
||||
.map((s) => s.driverData),
|
||||
);
|
||||
for (const datum of data) {
|
||||
if (!datum) {
|
||||
throw new Error(
|
||||
`Problem getting session data for driver type ` + `${InnerDriver.name}; does it implement 'get driverData'?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends one session: removes it from the master list immediately, then delegates to the inner
|
||||
* driver’s `deleteSession` with sibling-session metadata.
|
||||
* driver’s `deleteSession`.
|
||||
*/
|
||||
async deleteSession(sessionId: string): Promise<SessionHandlerDeleteResult> {
|
||||
let protocol: Protocol | undefined;
|
||||
try {
|
||||
let otherSessionsData: DriverData[] | undefined;
|
||||
let dstSession: ExternalDriver | undefined;
|
||||
if (this.sessions[sessionId]) {
|
||||
const curConstructorName = this.sessions[sessionId].constructor.name;
|
||||
otherSessionsData = Object.entries(this.sessions)
|
||||
.filter(([key, value]) => value.constructor.name === curConstructorName && key !== sessionId)
|
||||
.map(([, value]) => value.driverData);
|
||||
dstSession = this.sessions[sessionId];
|
||||
const dstSession: ExternalDriver | undefined = this.sessions[sessionId];
|
||||
if (dstSession) {
|
||||
protocol = dstSession.protocol;
|
||||
this.cleanupBidiSockets(sessionId);
|
||||
}
|
||||
@@ -507,7 +459,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
|
||||
return {
|
||||
protocol,
|
||||
value: await dstSession.deleteSession(sessionId, otherSessionsData),
|
||||
value: await dstSession.deleteSession(sessionId),
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -753,10 +705,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
}
|
||||
// we also want to assign the IPC channel for this session to the plugins
|
||||
const extPlugin = p as unknown as IpcAssignable;
|
||||
if (typeof extPlugin.assignIpc === 'function') {
|
||||
// TODO remove this existence guard as a breaking change in Appium 4
|
||||
await extPlugin.assignIpc(this.sessionIpcs[newSessionId]);
|
||||
}
|
||||
await extPlugin.assignIpc(this.sessionIpcs[newSessionId]);
|
||||
}
|
||||
this.sessionlessPlugins = [];
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ import type {
|
||||
} from '@appium/types';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import type {AppiumDriver} from './appium';
|
||||
import {BIDI_BASE_PATH, BIDI_EVENT_NAME} from './constants';
|
||||
import {fetchInterfaces, isBroadcastIp, V4_BROADCAST_IP} from './helpers/network';
|
||||
import {capitalize} from './utils';
|
||||
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;
|
||||
type AnyDriver = ExternalDriver | AppiumDriver;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {env, util} from '@appium/support';
|
||||
import {console as supportConsole, util} from '@appium/support';
|
||||
import type {DriverOpts} from '@appium/types';
|
||||
import type {
|
||||
Args,
|
||||
@@ -7,31 +7,31 @@ import type {
|
||||
CliCommandSetupSubcommand,
|
||||
CliExtensionSubcommand,
|
||||
ParsedArgs,
|
||||
} from 'appium/types';
|
||||
} from 'appium/types/index.js';
|
||||
|
||||
import {AppiumDriver, type AppiumDriverConstraints} from '../appium';
|
||||
import {runExtensionCommand} from '../cli/extension';
|
||||
import {injectAppiumSymlinks} from '../cli/extension-command';
|
||||
import {ArgParser, getParser} from '../cli/parser';
|
||||
import {runSetupCommand} from '../cli/setup-command';
|
||||
import {SERVER_SUBCOMMAND} from '../constants';
|
||||
import {type ExtensionConfigs, loadExtensions} from '../extension';
|
||||
import {log as logger} from '../logger';
|
||||
import {init as logsinkInit} from '../logsink';
|
||||
import {AppiumDriver, type AppiumDriverConstraints} from '../appium.js';
|
||||
import {injectAppiumSymlinks} from '../cli/extension-command.js';
|
||||
import {runExtensionCommand} from '../cli/extension.js';
|
||||
import {ArgParser, getParser} from '../cli/parser.js';
|
||||
import {runSetupCommand} from '../cli/setup-command.js';
|
||||
import {SERVER_SUBCOMMAND} from '../constants.js';
|
||||
import {type ExtensionConfigs, loadExtensions} from '../extension/index.js';
|
||||
import {log as logger} from '../logger.js';
|
||||
import {init as logsinkInit} from '../logsink.js';
|
||||
import {
|
||||
isDriverCommandArgs,
|
||||
isExtensionCommandArgs,
|
||||
isPluginCommandArgs,
|
||||
isServerCommandArgs,
|
||||
isSetupCommandArgs,
|
||||
} from '../schema/cli-args-guards';
|
||||
import {getDefaultsForSchema} from '../schema/schema';
|
||||
import {defaultsDeep} from '../utils';
|
||||
import {readConfigFile} from './config-file';
|
||||
import type {InitResult, PreConfigArgs} from './init-types';
|
||||
import {determineAppiumHomeSource, preflightChecks} from './main-helpers';
|
||||
import {adjustNodePath, requireDir, showDebugInfo} from './node-helpers';
|
||||
import {getNonDefaultServerArgs, showConfig} from './startup-config';
|
||||
} from '../schema/cli-args-guards.js';
|
||||
import {getDefaultsForSchema} from '../schema/schema.js';
|
||||
import {defaultsDeep, resolveAppiumHome} from '../utils/index.js';
|
||||
import {readConfigFile} from './config-file.js';
|
||||
import type {InitResult, PreConfigArgs} from './init-types.js';
|
||||
import {determineAppiumHomeSource, preflightChecks} from './main-helpers.js';
|
||||
import {adjustNodePath, requireDir, showDebugInfo} from './node-helpers.js';
|
||||
import {getNonDefaultServerArgs, showConfig} from './startup-config.js';
|
||||
|
||||
/**
|
||||
* Parses CLI/programmatic args, loads config and extensions, and returns server-ready state or runs extension/setup flows.
|
||||
@@ -51,7 +51,7 @@ export class AppiumInitializer {
|
||||
>(args?: Args<Cmd, SubCmd>): Promise<InitResult<Cmd>> {
|
||||
this.throwInsteadOfExit = false;
|
||||
|
||||
const appiumHome = args?.appiumHome ?? (await env.resolveAppiumHome());
|
||||
const appiumHome = args?.appiumHome ?? (await resolveAppiumHome());
|
||||
const appiumHomeSourceName = determineAppiumHomeSource(args?.appiumHome);
|
||||
await requireDir(appiumHome, false, appiumHomeSourceName);
|
||||
|
||||
@@ -77,6 +77,7 @@ export class AppiumInitializer {
|
||||
}
|
||||
|
||||
if (isSetupCommandArgs(preConfigArgs)) {
|
||||
this.printValidationSummaries(driverConfig, pluginConfig);
|
||||
await runSetupCommand(preConfigArgs, driverConfig, pluginConfig);
|
||||
return {} as InitResult<Cmd>;
|
||||
}
|
||||
@@ -135,6 +136,7 @@ export class AppiumInitializer {
|
||||
|
||||
if (preConfigArgs.showConfig) {
|
||||
showConfig(getNonDefaultServerArgs(preConfigArgs as Args), configResult, defaults, serverArgs);
|
||||
this.printValidationSummaries(driverConfig, pluginConfig);
|
||||
return {} as InitResult<Cmd>;
|
||||
}
|
||||
|
||||
@@ -144,10 +146,13 @@ export class AppiumInitializer {
|
||||
pluginConfig,
|
||||
appiumHome,
|
||||
});
|
||||
this.printValidationSummaries(driverConfig, pluginConfig);
|
||||
return {} as InitResult<Cmd>;
|
||||
}
|
||||
|
||||
await logsinkInit(serverArgs);
|
||||
// Deferred from loadExtensions() so these render through the now-active (Winston-backed) logger.
|
||||
this.printValidationSummaries(driverConfig, pluginConfig);
|
||||
await this.applyLogFilters(serverArgs);
|
||||
|
||||
if (!serverArgs.noPermsCheck) {
|
||||
@@ -167,6 +172,15 @@ export class AppiumInitializer {
|
||||
} as InitResult<Cmd>;
|
||||
}
|
||||
|
||||
/** Renders each config's pending `loadExtensions()`-time validation summary via the raw logger. */
|
||||
private printValidationSummaries(
|
||||
driverConfig: ExtensionConfigs['driverConfig'],
|
||||
pluginConfig: ExtensionConfigs['pluginConfig'],
|
||||
): void {
|
||||
driverConfig.printValidationSummary(logger);
|
||||
pluginConfig.printValidationSummary(logger);
|
||||
}
|
||||
|
||||
private async applyLogFilters(serverArgs: ParsedArgs<CliCommandServer>): Promise<void> {
|
||||
if (!serverArgs.logFilters) {
|
||||
return;
|
||||
@@ -201,7 +215,10 @@ export class AppiumInitializer {
|
||||
if (isDriverCommandArgs(preConfigArgs) || isPluginCommandArgs(preConfigArgs)) {
|
||||
const cmd = isDriverCommandArgs(preConfigArgs) ? preConfigArgs.driverCommand : preConfigArgs.pluginCommand;
|
||||
if (cmd === 'install') {
|
||||
await injectAppiumSymlinks(driverConfig, pluginConfig, logger);
|
||||
// Match the same command's own console so a non-fatal symlink error never lands on
|
||||
// STDOUT under `--json` (unlike the raw npmlog logger, which isn't JSON-mode-aware).
|
||||
const symlinkLog = new supportConsole.CliConsole({jsonMode: Boolean(preConfigArgs.json)});
|
||||
await injectAppiumSymlinks(driverConfig, pluginConfig, symlinkLog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,33 @@ import net from 'node:net';
|
||||
import type {ServerOpts} from '@appium/base-driver';
|
||||
import {util} from '@appium/support';
|
||||
import type {AppiumServer} from '@appium/types';
|
||||
import type {Args, CliCommand, CliCommandServer, CliCommandSetupSubcommand, CliExtensionSubcommand} from 'appium/types';
|
||||
import type {
|
||||
Args,
|
||||
CliCommand,
|
||||
CliCommandServer,
|
||||
CliCommandSetupSubcommand,
|
||||
CliExtensionSubcommand,
|
||||
} from 'appium/types/index.js';
|
||||
|
||||
import {getActiveDrivers, getActivePlugins} from '../extension';
|
||||
import {log as logger} from '../logger';
|
||||
import registerNode from './grid-v3-register';
|
||||
import type {InitResult, ServerInitData} from './init-types';
|
||||
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';
|
||||
} from './main-helpers.js';
|
||||
|
||||
const MAX_SERVER_PROCESS_LISTENERS = 100;
|
||||
|
||||
/**
|
||||
* Starts the Appium HTTP server after {@link AppiumInitializer.init}: loads drivers/plugins, binds, grid register, signals.
|
||||
* Starts the Appium HTTP server after {@link AppiumInitializer.init}: loads drivers/plugins, binds, signals.
|
||||
*/
|
||||
export class AppiumMainRunner {
|
||||
/**
|
||||
* For server init: builds listeners, registers with Grid 3 if configured, and returns the server.
|
||||
* For server init: builds listeners and returns the server.
|
||||
* For non-server commands, `initResult` is empty and this resolves to `undefined`.
|
||||
*
|
||||
* @param initResult - Output of {@link AppiumInitializer.init}
|
||||
@@ -55,6 +60,8 @@ export class AppiumMainRunner {
|
||||
await logStartupInfo(parsedArgs);
|
||||
|
||||
appiumDriver.configureGlobalFeatures();
|
||||
// the rules are enforced by the shared app download helper of every driver
|
||||
appiumDriver.helpers.setAppUrlRules(parsedArgs.appUrlRules);
|
||||
|
||||
const appiumHomeSourceName = determineAppiumHomeSource(args?.appiumHome);
|
||||
logger.debug(`The ${appiumHomeSourceName}: ${appiumHome}`);
|
||||
@@ -71,10 +78,9 @@ export class AppiumMainRunner {
|
||||
return undefined as Cmd extends CliCommandServer ? AppiumServer : void;
|
||||
}
|
||||
|
||||
this.warnIfCorsEnabled(parsedArgs);
|
||||
this.warnIfCorsEnabled(serverOpts);
|
||||
appiumDriver.server = server;
|
||||
|
||||
await this.registerGridOrClose(server, parsedArgs, normalizedBasePath);
|
||||
this.attachSignalHandlers(appiumDriver, server);
|
||||
this.logListeningUrl(server, parsedArgs, normalizedBasePath);
|
||||
|
||||
@@ -104,8 +110,8 @@ export class AppiumMainRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private warnIfCorsEnabled(parsedArgs: ServerInitData['parsedArgs']): void {
|
||||
if (parsedArgs.allowCors) {
|
||||
private warnIfCorsEnabled(serverOpts: ServerOpts): void {
|
||||
if (serverOpts.allowCors) {
|
||||
logger.warn(
|
||||
'You have enabled CORS requests from any host. Be careful not ' +
|
||||
'to visit sites which could maliciously try to start Appium ' +
|
||||
@@ -114,21 +120,6 @@ export class AppiumMainRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private async registerGridOrClose(
|
||||
server: AppiumServer,
|
||||
parsedArgs: ServerInitData['parsedArgs'],
|
||||
normalizedBasePath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (parsedArgs.nodeconfig) {
|
||||
await registerNode(parsedArgs.nodeconfig, parsedArgs.address, parsedArgs.port, normalizedBasePath);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
await server.close();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private attachSignalHandlers(appiumDriver: ServerInitData['appiumDriver'], server: AppiumServer): void {
|
||||
process.setMaxListeners(MAX_SERVER_PROCESS_LISTENERS);
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {util} from '@appium/support';
|
||||
import type {AppiumConfig, NormalizedAppiumConfig} from '@appium/types';
|
||||
import type {IOutputError} from '@sidvind/better-ajv-errors';
|
||||
import type {ErrorObject, SchemaObject} from 'ajv';
|
||||
import {lilconfig, type LilconfigResult, type LoaderSync} from 'lilconfig';
|
||||
import * as yaml from 'yaml';
|
||||
|
||||
import {formatErrors} from '../schema/format-errors';
|
||||
import {getSchema, validate} from '../schema/schema';
|
||||
import {camelCase, getPath, mapKeys, mapValues} from '../utils';
|
||||
import type {ErrorObject, SchemaObject} from '../schema/ajv.js';
|
||||
import {formatErrors} from '../schema/format-errors.js';
|
||||
import {getSchema, validate} from '../schema/schema.js';
|
||||
import {camelCase, getPath, mapKeys, mapValues} from '../utils/index.js';
|
||||
|
||||
/**
|
||||
* A cache of the raw config file (a JSON string) at a filepath.
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import {fs} from '@appium/support';
|
||||
import type {StringRecord} from '@appium/types';
|
||||
import axios from 'axios';
|
||||
|
||||
import {log as logger} from '../logger';
|
||||
|
||||
/**
|
||||
* Selenium **Grid 3** (legacy hub) node integration.
|
||||
*
|
||||
* Registers this Appium process as a node with a Selenium Grid 3 hub using the classic
|
||||
* `/grid/register` endpoint and `/grid/api/proxy` for re-registration checks.
|
||||
* This does **not** apply to Selenium Grid 4 (which uses a different topology and APIs).
|
||||
*
|
||||
* @see https://www.selenium.dev/documentation/legacy/grid_3/
|
||||
*/
|
||||
|
||||
/** REST paths exposed by a Selenium Grid 3 hub. */
|
||||
const GRID_V3_REGISTER_PATH = '/grid/register';
|
||||
const GRID_V3_PROXY_API_PATH = '/grid/api/proxy';
|
||||
|
||||
interface Grid3HubConfiguration {
|
||||
hubProtocol?: string;
|
||||
hubHost?: string;
|
||||
hubPort?: number;
|
||||
url?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
register?: boolean;
|
||||
registerCycle?: number;
|
||||
}
|
||||
|
||||
interface Grid3NodeConfig extends StringRecord {
|
||||
configuration?: Grid3HubConfiguration;
|
||||
capabilities?: unknown;
|
||||
}
|
||||
|
||||
interface Grid3ProxyApiResponse {
|
||||
success: boolean;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export default async function registerNode(data: string, addr: string, port: number, basePath: string): Promise<void>;
|
||||
export default async function registerNode(
|
||||
data: Grid3NodeConfig,
|
||||
addr?: string,
|
||||
port?: number,
|
||||
basePath?: string,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Registers this server as a node with a **Selenium Grid 3** hub.
|
||||
*
|
||||
* When `data` is a path to a JSON config file, `addr`, `port`, and `basePath` are required (they define
|
||||
* how the hub reaches this Appium server). When `data` is an inline config object, those parameters are
|
||||
* optional and only used to fill missing node URL fields in the config.
|
||||
*
|
||||
* @deprecated Selenium Grid 3 registration is legacy and slated for removal.
|
||||
* Use Selenium Grid 4 integration (node relay) instead.
|
||||
*
|
||||
* @param data - Path to a JSON file or an inline parsed config object
|
||||
* @param addr - Bind to this address
|
||||
* @param port - Bind to this port
|
||||
* @param basePath - Base path for the Appium server (used in the node URL sent to the hub; may be `''`)
|
||||
*/
|
||||
export default async function registerNode(
|
||||
data: string | Grid3NodeConfig,
|
||||
addr?: string,
|
||||
port?: number,
|
||||
basePath?: string,
|
||||
): Promise<void> {
|
||||
if (typeof data === 'string') {
|
||||
if (addr === undefined || port === undefined || basePath === undefined) {
|
||||
throw logger.errorWithException(
|
||||
'When the first argument is a Selenium Grid 3 node config file path, address, port, and basePath ' +
|
||||
'are required (e.g. match your Appium `--address`, `--port`, and base path).',
|
||||
);
|
||||
}
|
||||
if (typeof port !== 'number' || !Number.isFinite(port)) {
|
||||
throw logger.errorWithException('When registering from a node config file path, port must be a finite number.');
|
||||
}
|
||||
}
|
||||
|
||||
let configHolder: Grid3NodeConfig;
|
||||
if (typeof data === 'string') {
|
||||
const configFilePath = data;
|
||||
let fileContent: string;
|
||||
try {
|
||||
fileContent = await fs.readFile(data, 'utf-8');
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Unable to load Selenium Grid 3 node configuration file ${configFilePath} to ` +
|
||||
`register with the hub: ${(err as Error).message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
configHolder = JSON.parse(fileContent) as Grid3NodeConfig;
|
||||
} catch (err) {
|
||||
throw logger.errorWithException(
|
||||
`Syntax error in Selenium Grid 3 node configuration file ${configFilePath}: ` + (err as Error).message,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
configHolder = data;
|
||||
}
|
||||
|
||||
postRequest(configHolder, addr, port, basePath);
|
||||
}
|
||||
|
||||
/** Base URL for the Selenium Grid 3 hub (protocol + host + port). */
|
||||
function hubUri(config: Grid3HubConfiguration): string {
|
||||
const protocol = config.hubProtocol || 'http';
|
||||
return `${protocol}://${config.hubHost}:${config.hubPort}`;
|
||||
}
|
||||
|
||||
/** POST registration payload to the Selenium Grid 3 hub. */
|
||||
async function registerToGrid(
|
||||
postOptions: {url: string; method: string; data: Grid3NodeConfig},
|
||||
configHolder: Grid3NodeConfig,
|
||||
): Promise<void> {
|
||||
const hubCfg = configHolder.configuration;
|
||||
if (!hubCfg) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const {status} = await axios(postOptions);
|
||||
if (status !== 200) {
|
||||
throw new Error(`Request failed with code ${status}`);
|
||||
}
|
||||
logger.debug(`Appium successfully registered with the Selenium Grid 3 hub at ` + hubUri(hubCfg));
|
||||
} catch (err) {
|
||||
logger.error(`An attempt to register with the Selenium Grid 3 hub was unsuccessful: ` + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function postRequest(configHolder: Grid3NodeConfig, addr?: string, port?: number, basePath?: string): void {
|
||||
// Move Selenium Grid 3 (flat) configuration properties into `configuration`
|
||||
if (configHolder != null && typeof configHolder === 'object' && !Object.hasOwn(configHolder, 'configuration')) {
|
||||
const configuration: StringRecord = {};
|
||||
const holder = configHolder as StringRecord;
|
||||
for (const property of Object.keys(holder)) {
|
||||
if (property !== 'capabilities') {
|
||||
configuration[property] = holder[property];
|
||||
delete holder[property];
|
||||
}
|
||||
}
|
||||
holder.configuration = configuration as Grid3HubConfiguration;
|
||||
}
|
||||
|
||||
const cfg = configHolder.configuration;
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the node config does not have the appium/webdriver url, host, and port,
|
||||
// automatically add it based on how appium was initialized
|
||||
// otherwise, we will take whatever the user setup
|
||||
// because we will always set localhost/127.0.0.1. this won't work if your
|
||||
// node and hub aren't in the same place
|
||||
if (!cfg.url || !cfg.host || !cfg.port) {
|
||||
cfg.url = `http://${addr}:${port}${basePath}`;
|
||||
cfg.host = addr;
|
||||
cfg.port = port;
|
||||
}
|
||||
// if the node config does not have id automatically add it
|
||||
if (!cfg.id) {
|
||||
cfg.id = `http://${cfg.host}:${cfg.port}`;
|
||||
}
|
||||
|
||||
// the post options
|
||||
const regRequest = {
|
||||
url: `${hubUri(cfg)}${GRID_V3_REGISTER_PATH}`,
|
||||
method: 'POST',
|
||||
data: configHolder,
|
||||
};
|
||||
|
||||
if (cfg.register !== true) {
|
||||
logger.debug(`No Selenium Grid 3 hub registration sent (${cfg.register} = false)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const registerCycleInterval = cfg.registerCycle;
|
||||
if (registerCycleInterval === undefined || isNaN(registerCycleInterval) || registerCycleInterval <= 0) {
|
||||
logger.warn(
|
||||
`'registerCycle' is not a valid positive number. ` +
|
||||
`No registration request will be sent to the Selenium Grid 3 hub.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// initiate a new Thread
|
||||
let first = true;
|
||||
logger.debug(
|
||||
`Starting auto-register thread for Selenium Grid 3. ` + `Will try to register every ${registerCycleInterval} ms.`,
|
||||
);
|
||||
setInterval(async function registerRetry() {
|
||||
if (first) {
|
||||
first = false;
|
||||
await registerToGrid(regRequest, configHolder);
|
||||
} else if (!(await isAlreadyRegistered(configHolder))) {
|
||||
// make the http POST to the Selenium Grid 3 hub for registration
|
||||
await registerToGrid(regRequest, configHolder);
|
||||
}
|
||||
}, registerCycleInterval);
|
||||
}
|
||||
|
||||
/** Query the Selenium Grid 3 hub to see if this node id is already registered. */
|
||||
async function isAlreadyRegistered(configHolder: Grid3NodeConfig): Promise<boolean | undefined> {
|
||||
// check if node is already registered
|
||||
const hubCfg = configHolder.configuration;
|
||||
if (!hubCfg?.id) {
|
||||
return;
|
||||
}
|
||||
const id = hubCfg.id;
|
||||
try {
|
||||
const {data, status} = await axios<Grid3ProxyApiResponse>({
|
||||
url: `${hubUri(hubCfg)}${GRID_V3_PROXY_API_PATH}`,
|
||||
params: {id},
|
||||
timeout: 10000,
|
||||
});
|
||||
if (status !== 200) {
|
||||
throw new Error(`Request failed with code ${status}`);
|
||||
}
|
||||
if (!data.success) {
|
||||
// if register fail, print the debug msg
|
||||
logger.debug(`Selenium Grid 3 hub registration check: ${data.msg}`);
|
||||
}
|
||||
return data.success;
|
||||
} catch (err) {
|
||||
logger.debug(`Selenium Grid 3 hub down or not responding: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import type {
|
||||
CliCommandSetupSubcommand,
|
||||
CliExtensionSubcommand,
|
||||
ParsedArgs,
|
||||
} from 'appium/types';
|
||||
} from 'appium/types/index.js';
|
||||
|
||||
import type {AppiumDriver} from '../appium';
|
||||
import type {ExtensionConfigs} from '../extension';
|
||||
import type {AppiumDriver} from '../appium.js';
|
||||
import type {ExtensionConfigs} from '../extension/index.js';
|
||||
|
||||
/** Empty object returned when `init` completes for non-server CLI flows (extension/setup). */
|
||||
export type ExtCommandInitResult = Record<string, never>;
|
||||
|
||||
@@ -9,18 +9,18 @@ import {
|
||||
} from '@appium/base-driver';
|
||||
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';
|
||||
import type {Args, CliCommandServer, ParsedArgs} from 'appium/types/index.js';
|
||||
import {WebSocketServer} from 'ws';
|
||||
|
||||
import type {AppiumDriver} from '../appium';
|
||||
import {BIDI_BASE_PATH, LONG_STACKTRACE_LIMIT} from '../constants';
|
||||
import type {DriverNameMap, PluginNameMap} from '../extension';
|
||||
import {APPIUM_VER, getBuildInfo, getGitRev, updateBuildInfo} from '../helpers/build';
|
||||
import {fetchInterfaces, isBroadcastIp, V4_BROADCAST_IP} from '../helpers/network';
|
||||
import {log as logger} from '../logger';
|
||||
import {validate as validateSchema} from '../schema/schema';
|
||||
import {checkNodeOk, requireDir} from './node-helpers';
|
||||
import {getNonDefaultServerArgs} from './startup-config';
|
||||
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';
|
||||
import {getNonDefaultServerArgs} from './startup-config.js';
|
||||
|
||||
const isStdoutTTY = process.stdout.isTTY;
|
||||
|
||||
@@ -173,7 +173,7 @@ export function buildServerOpts(
|
||||
routeConfiguringFunction,
|
||||
port: parsedArgs.port,
|
||||
hostname: parsedArgs.address,
|
||||
allowCors: parsedArgs.allowCors,
|
||||
allowCors: appiumDriver.isFeatureEnabled(CORS_FEATURE),
|
||||
basePath: parsedArgs.basePath,
|
||||
serverUpdaters: getServerUpdaters(driverClasses, pluginClasses),
|
||||
extraMethodMap: getExtraMethodMap(driverClasses, pluginClasses),
|
||||
@@ -206,9 +206,18 @@ export async function createAppiumServer(
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server args whose values describe the server's internal network and must not leak to clients
|
||||
* via server logs
|
||||
*/
|
||||
const SENSITIVE_ARGS: ReadonlySet<keyof Args> = new Set(['appUrlRules']);
|
||||
|
||||
function logNonDefaultArgsWarning(args: Args): void {
|
||||
const shownArgs = Object.fromEntries(
|
||||
Object.entries(args).map(([name, value]) => [name, SENSITIVE_ARGS.has(name as keyof Args) ? '<redacted>' : value]),
|
||||
);
|
||||
logger.info('Non-default server args:');
|
||||
inspect(args);
|
||||
inspect(shownArgs);
|
||||
}
|
||||
|
||||
function logDefaultCapabilitiesWarning(caps: ParsedArgs<CliCommandServer>['defaultCapabilities']): void {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import {Module} from 'node:module';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import {fs, npm, system} from '@appium/support';
|
||||
import {fs, system} from '@appium/support';
|
||||
import * as semver from 'semver';
|
||||
|
||||
import {getBuildInfo, updateBuildInfo} from '../helpers/build';
|
||||
import {appiumPackageRoot, npmPackage} from '../utils';
|
||||
import {getBuildInfo, updateBuildInfo} from '../helpers/build.js';
|
||||
import {appiumPackageRoot, npm, npmPackage} from '../utils/index.js';
|
||||
|
||||
const MIN_NODE_VERSION = (npmPackage.engines as Record<string, string>).node;
|
||||
|
||||
interface NodeModuleWithInitPaths {
|
||||
Module: {_initPaths(): void};
|
||||
interface ModuleWithInitPaths {
|
||||
_initPaths(): void;
|
||||
}
|
||||
|
||||
interface DebugInfoInput {
|
||||
@@ -31,15 +32,19 @@ export function checkNodeOk(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts NODE_PATH so CJS drivers/plugins can load peer deps. Does not work with ESM.
|
||||
* Adjusts NODE_PATH so CJS drivers/plugins can load peer deps. Does not help ESM
|
||||
* drivers/plugins, since real ESM `import` resolution never consults NODE_PATH.
|
||||
*/
|
||||
export function adjustNodePath(): void {
|
||||
const appiumModuleSearchRoot = path.dirname(appiumPackageRoot);
|
||||
|
||||
const refreshRequirePaths = (): boolean => {
|
||||
try {
|
||||
// Private API; see https://gist.github.com/branneman/8048520#7-the-hack
|
||||
(require('node:module') as NodeModuleWithInitPaths).Module._initPaths();
|
||||
// `Module._initPaths()` re-reads `process.env.NODE_PATH` into Node's global CJS module
|
||||
// search paths; a CJS driver/plugin's own later `require()` calls (even though appium
|
||||
// itself is ESM now) see the update, since this affects the whole process, not just the
|
||||
// caller. Private API; see https://gist.github.com/branneman/8048520#7-the-hack
|
||||
(Module as unknown as ModuleWithInitPaths)._initPaths();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable no-console */
|
||||
import {util} from '@appium/support';
|
||||
import type {Args} from 'appium/types';
|
||||
import type {Args} from 'appium/types/index.js';
|
||||
|
||||
import {getAllArgSpecs, getDefaultsForSchema} from '../schema/schema';
|
||||
import {difference, getPath, pickBy, setPath} from '../utils';
|
||||
import type {ReadConfigFileResult} from './config-file';
|
||||
import {getAllArgSpecs, getDefaultsForSchema} from '../schema/schema.js';
|
||||
import {difference, getPath, pickBy, setPath} from '../utils/index.js';
|
||||
import type {ReadConfigFileResult} from './config-file.js';
|
||||
|
||||
interface FlattenedArg {
|
||||
value: unknown;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {util} from '@appium/support';
|
||||
import type {ExtensionType} from '@appium/types';
|
||||
import type {CliExtensionSubcommand} from 'appium/types';
|
||||
import type {ArgumentOptions} from 'argparse';
|
||||
import type {CliExtensionSubcommand} from 'appium/types/index.js';
|
||||
|
||||
import {
|
||||
DRIVER_TYPE,
|
||||
@@ -12,14 +11,13 @@ import {
|
||||
EXT_SUBCOMMAND_UNINSTALL,
|
||||
EXT_SUBCOMMAND_UPDATE,
|
||||
PLUGIN_TYPE,
|
||||
} from '../constants';
|
||||
import {INSTALL_TYPES} from '../extension/extension-config';
|
||||
import {toParserArgs} from '../schema/cli-args';
|
||||
} from '../constants.js';
|
||||
import {INSTALL_TYPES} from '../extension/manifest/index.js';
|
||||
import {toParserArgs} from '../schema/cli-args.js';
|
||||
import type {ArgumentDefinitions} from '../schema/cli-args.js';
|
||||
const DRIVER_EXAMPLE = 'xcuitest';
|
||||
const PLUGIN_EXAMPLE = 'images';
|
||||
|
||||
export type ArgumentDefinitions = Map<[name: string] | [name: string, alias: string], ArgumentOptions>;
|
||||
|
||||
/**
|
||||
* This is necessary because we pass the array into `argparse`. `argparse` is bad and mutates things. We don't want that.
|
||||
* Bad `argparse`! Bad!
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {console, util} from '@appium/support';
|
||||
import type {ExtMetadata, ExtRecord, InstallType} from 'appium/types';
|
||||
import type {ExtMetadata, ExtRecord, InstallType} from 'appium/types/index.js';
|
||||
|
||||
import {KNOWN_DRIVERS} from '../constants';
|
||||
import {ExtensionCliCommand} from './extension-command';
|
||||
import {KNOWN_DRIVERS} from '../constants.js';
|
||||
import {ExtensionCliCommand} from './extension-command.js';
|
||||
import type {
|
||||
ExtensionArgs,
|
||||
ExtensionCommandOptions,
|
||||
ExtensionUpdateResult,
|
||||
PostInstallText,
|
||||
RunOutput,
|
||||
} from './extension-command';
|
||||
} from './extension-command.js';
|
||||
const REQ_DRIVER_FIELDS = ['driverName', 'automationName', 'platformNames', 'mainClass'];
|
||||
type DriverInstallOpts = {driver: string; installType: InstallType; packageName?: string};
|
||||
type DriverUninstallOpts = {driver: string};
|
||||
|
||||
@@ -3,8 +3,8 @@ import path from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
import {inspect} from 'node:util';
|
||||
|
||||
import {console, env, fs, npm, system, util} from '@appium/support';
|
||||
import type {AppiumLogger, ExtensionType, IDoctorCheck} from '@appium/types';
|
||||
import {console, fs, system, util} from '@appium/support';
|
||||
import type {ExtensionType, IDoctorCheck} from '@appium/types';
|
||||
import type {
|
||||
ExtInstallReceipt as AppiumExtInstallReceipt,
|
||||
ExtManifest as AppiumExtManifest,
|
||||
@@ -12,23 +12,23 @@ import type {
|
||||
ExtPackageJson as AppiumExtPackageJson,
|
||||
ExtRecord as AppiumExtRecord,
|
||||
InstallType,
|
||||
} from 'appium/types';
|
||||
} from 'appium/types/index.js';
|
||||
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';
|
||||
import type {ExtensionConfig as BaseExtensionConfig} from '../extension/extension-config';
|
||||
import {Doctor, EXIT_CODE as DOCTOR_EXIT_CODE} from '../doctor/doctor.js';
|
||||
import type {ExtensionConfig as BaseExtensionConfig} from '../extension/extension-config.js';
|
||||
import {
|
||||
INSTALL_TYPE_DEV,
|
||||
INSTALL_TYPE_GIT,
|
||||
INSTALL_TYPE_GITHUB,
|
||||
INSTALL_TYPE_LOCAL,
|
||||
INSTALL_TYPE_NPM,
|
||||
} from '../extension/extension-config';
|
||||
import {appiumPackageRoot, compact, npmPackage, packageDidChange} from '../utils';
|
||||
import {RingBuffer, spinWith} from './utils';
|
||||
} from '../extension/manifest/index.js';
|
||||
import {appiumPackageRoot, compact, hasAppiumDependency, npm, npmPackage, packageDidChange} from '../utils/index.js';
|
||||
import {RingBuffer, spinWith} from './utils.js';
|
||||
|
||||
const UPDATE_ALL = 'installed';
|
||||
const MAX_CONCURRENT_REPO_FETCHES = 5;
|
||||
@@ -161,6 +161,9 @@ type GetInstallationReceiptOpts<ExtType extends ExtensionType = ExtensionType> =
|
||||
|
||||
type InstalledExtensionLike = {installType?: InstallType; installPath?: string};
|
||||
|
||||
/** Minimal logger shape accepted where callers must control whether output can hit `STDOUT`. */
|
||||
type InfoSink = {info(message?: string, ...args: any[]): void};
|
||||
|
||||
class NotUpdatableError extends Error {}
|
||||
|
||||
class NoUpdatesAvailableError extends Error {}
|
||||
@@ -193,6 +196,15 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
this.isJsonOutput = Boolean(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the config's pending manifest-validation summary (from the `loadExtensions()` call at
|
||||
* CLI startup) through this command's own JSON-mode-aware console, so it's suppressed under
|
||||
* `--json` just like any other non-JSON output.
|
||||
*/
|
||||
printPendingValidationSummary(): void {
|
||||
this.config.printValidationSummary(this.log);
|
||||
}
|
||||
|
||||
/**
|
||||
* `driver` or `plugin`, depending on the `ExtensionConfig`.
|
||||
*/
|
||||
@@ -399,7 +411,7 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
await this.config.addExtension(extName, extManifest as any);
|
||||
|
||||
// update the hash if we've changed the local `package.json`
|
||||
if (await env.hasAppiumDependency(this.config.appiumHome)) {
|
||||
if (await hasAppiumDependency(this.config.appiumHome)) {
|
||||
await packageDidChange(this.config.appiumHome);
|
||||
}
|
||||
|
||||
@@ -589,7 +601,7 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
const paths: string[] = doctorSpec.checks
|
||||
.map((p) => {
|
||||
const scriptPath = path.resolve(moduleRoot, p);
|
||||
if (!path.normalize(scriptPath).startsWith(path.normalize(moduleRoot))) {
|
||||
if (!util.isSubPath(scriptPath, moduleRoot)) {
|
||||
this.log.error(
|
||||
`The doctor check script '${p}' from the package manifest '${packageJsonPath}' must be located ` +
|
||||
`in the '${moduleRoot}' root folder. It will be skipped`,
|
||||
@@ -694,8 +706,7 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
|
||||
const scriptPath = extScripts[scriptName];
|
||||
const moduleRoot = this.config.getInstallPath(installSpec);
|
||||
const normalizedScriptPath = path.normalize(path.resolve(moduleRoot, scriptPath));
|
||||
if (!normalizedScriptPath.startsWith(path.normalize(moduleRoot))) {
|
||||
if (!util.isSubPath(path.resolve(moduleRoot, scriptPath), moduleRoot)) {
|
||||
throw this._createFatalError(`The '${scriptPath}' script must be located in the '${moduleRoot}' folder`);
|
||||
}
|
||||
|
||||
@@ -791,7 +802,12 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
const {pkg, installPath} = await spinWith(
|
||||
this.isJsonOutput,
|
||||
installMsg,
|
||||
async () => await npm.installPackage(appiumHome, installStr, {pkgName, installType}),
|
||||
async () =>
|
||||
await npm.installPackage(appiumHome, installStr, {
|
||||
pkgName,
|
||||
installType,
|
||||
hasAppiumDependency: await hasAppiumDependency(appiumHome),
|
||||
}),
|
||||
);
|
||||
|
||||
const validatedPkg = await spinWith(this.isJsonOutput, validateMsg, async () =>
|
||||
@@ -1149,13 +1165,14 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
*
|
||||
* @param driverConfig - active driver extension config
|
||||
* @param pluginConfig - active plugin extension config
|
||||
* @param logger - logger instance used for non-fatal symlink errors
|
||||
* @param logger - sink for non-fatal symlink errors; pass a JSON-mode-aware `CliConsole` so this
|
||||
* never writes to `STDOUT` under `--json`
|
||||
* @returns resolves when symlink injection has completed for all extensions
|
||||
*/
|
||||
export async function injectAppiumSymlinks(
|
||||
driverConfig: ExtensionConfig<any>,
|
||||
pluginConfig: ExtensionConfig<any>,
|
||||
logger: AppiumLogger,
|
||||
logger: InfoSink,
|
||||
): Promise<void> {
|
||||
const isNpmInstalledExtension = (
|
||||
details: InstalledExtensionLike,
|
||||
@@ -1222,7 +1239,7 @@ async function getRemoteExtensionVersionReq(pkgName: string, pkgVer?: string): P
|
||||
*
|
||||
* @param dstFolder The destination folder where the symlink should be created
|
||||
*/
|
||||
async function injectAppiumSymlink(dstFolder: string, logger: AppiumLogger): Promise<void> {
|
||||
async function injectAppiumSymlink(dstFolder: string, logger: InfoSink): Promise<void> {
|
||||
try {
|
||||
const symlinkPath = path.join(dstFolder, path.basename(appiumPackageRoot));
|
||||
if ((await fs.exists(dstFolder)) && !(await fs.exists(symlinkPath))) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/* eslint-disable no-console */
|
||||
import type {Class, DriverType, ExtensionType, PluginType} from '@appium/types';
|
||||
import type {Args, CliExtensionCommand, CliExtensionSubcommand} from 'appium/types';
|
||||
import type {Args, CliExtensionCommand, CliExtensionSubcommand} from 'appium/types/index.js';
|
||||
|
||||
import {DRIVER_TYPE, PLUGIN_TYPE} from '../constants';
|
||||
import type {ExtensionConfig} from '../extension/extension-config';
|
||||
import {isExtensionCommandArgs} from '../schema/cli-args-guards';
|
||||
import DriverCliCommand from './driver-command';
|
||||
import PluginCliCommand from './plugin-command';
|
||||
import {errAndQuit, JSON_SPACES} from './utils';
|
||||
import {DRIVER_TYPE, PLUGIN_TYPE} from '../constants.js';
|
||||
import type {ExtensionConfig} from '../extension/extension-config.js';
|
||||
import {reloadManifest} from '../extension/index.js';
|
||||
import {isExtensionCommandArgs} from '../schema/cli-args-guards.js';
|
||||
import {withManifestLock} from '../utils/index.js';
|
||||
import DriverCliCommand from './driver-command.js';
|
||||
import PluginCliCommand from './plugin-command.js';
|
||||
import {errAndQuit, JSON_SPACES} from './utils.js';
|
||||
|
||||
export const commandClasses = Object.freeze({
|
||||
[DRIVER_TYPE]: DriverCliCommand,
|
||||
@@ -30,9 +32,6 @@ export async function runExtensionCommand<Cmd extends CliExtensionCommand, SubCm
|
||||
args: Args<Cmd, SubCmd>,
|
||||
config: ExtensionConfig<Cmd>,
|
||||
) {
|
||||
// TODO driver config file should be locked while any of these commands are
|
||||
// running to prevent weird situations
|
||||
let jsonResult: Record<string, unknown> = {};
|
||||
const {extensionType: type} = config; // NOTE this is the same as `args.subcommand`
|
||||
if (!isExtensionCommandArgs(args)) {
|
||||
throw new TypeError(`Cannot call ${type} command without a subcommand like 'install'`);
|
||||
@@ -43,22 +42,35 @@ export async function runExtensionCommand<Cmd extends CliExtensionCommand, SubCm
|
||||
if (suppressOutput) {
|
||||
json = true;
|
||||
}
|
||||
const CommandClass = commandClasses[type] as ExtCommand<Cmd>;
|
||||
const cmd = new CommandClass({config, json} as any);
|
||||
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;
|
||||
|
||||
// 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);
|
||||
|
||||
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;
|
||||
}
|
||||
errAndQuit(json, err);
|
||||
}
|
||||
errAndQuit(json, err);
|
||||
}
|
||||
|
||||
if (json && !suppressOutput) {
|
||||
console.log(JSON.stringify(jsonResult, null, JSON_SPACES));
|
||||
}
|
||||
if (json && !suppressOutput) {
|
||||
console.log(JSON.stringify(jsonResult, null, JSON_SPACES));
|
||||
}
|
||||
|
||||
return jsonResult;
|
||||
return jsonResult;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type {DriverType, PluginType} from '@appium/types';
|
||||
import type {CliExtensionSubcommand} from 'appium/types';
|
||||
import type {CliExtensionSubcommand} from 'appium/types/index.js';
|
||||
import {ArgumentParser} from 'argparse';
|
||||
import type {SubArgumentParserOptions, SubparsersAction} from 'argparse';
|
||||
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
PLUGIN_TYPE,
|
||||
SERVER_SUBCOMMAND,
|
||||
SETUP_SUBCOMMAND,
|
||||
} from '../constants';
|
||||
import {APPIUM_VER} from '../helpers/build';
|
||||
import {finalizeSchema, getAllArgSpecs, getArgSpec, hasArgSpec} from '../schema';
|
||||
import {setPath} from '../utils';
|
||||
import {getExtensionArgs, getServerArgs} from './args';
|
||||
import type {ArgumentDefinitions} from './args';
|
||||
} from '../constants.js';
|
||||
import {APPIUM_VER} from '../helpers/build.js';
|
||||
import type {ArgumentDefinitions} from '../schema/index.js';
|
||||
import {finalizeSchema, getAllArgSpecs, getArgSpec, hasArgSpec} from '../schema/index.js';
|
||||
import {setPath} from '../utils/index.js';
|
||||
import {getExtensionArgs, getServerArgs} from './args.js';
|
||||
import {
|
||||
DEFAULT_PLUGINS,
|
||||
determinePlatformName,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
SUBCOMMAND_DESKTOP,
|
||||
SUBCOMMAND_MOBILE,
|
||||
SUBCOMMAND_RESET,
|
||||
} from './setup-command';
|
||||
} from './setup-command.js';
|
||||
|
||||
export const EXTRA_ARGS = 'extraArgs';
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {console, util} from '@appium/support';
|
||||
import type {ExtMetadata, ExtRecord, InstallType} from 'appium/types';
|
||||
import type {ExtMetadata, ExtRecord, InstallType} from 'appium/types/index.js';
|
||||
|
||||
import {KNOWN_PLUGINS} from '../constants';
|
||||
import {ExtensionCliCommand} from './extension-command';
|
||||
import {KNOWN_PLUGINS} from '../constants.js';
|
||||
import {ExtensionCliCommand} from './extension-command.js';
|
||||
import type {
|
||||
ExtensionArgs,
|
||||
ExtensionCommandOptions,
|
||||
ExtensionUpdateResult,
|
||||
PostInstallText,
|
||||
RunOutput,
|
||||
} from './extension-command';
|
||||
} from './extension-command.js';
|
||||
|
||||
const REQ_PLUGIN_FIELDS = ['pluginName', 'mainClass'];
|
||||
type PluginInstallOpts = {plugin: string; installType: InstallType; packageName?: string};
|
||||
|
||||
@@ -5,12 +5,12 @@ import type {
|
||||
CliCommandSetupSubcommand,
|
||||
CliExtensionCommand,
|
||||
CliExtensionSubcommand,
|
||||
} from 'appium/types';
|
||||
} from 'appium/types/index.js';
|
||||
|
||||
import {DESKTOP_BROWSERS, DESKTOP_DRIVERS, MOBILE_DRIVERS} from '../constants';
|
||||
import type {ExtensionConfig} from '../extension/extension-config';
|
||||
import {log} from '../logger';
|
||||
import {runExtensionCommand} from './extension';
|
||||
import {DESKTOP_BROWSERS, DESKTOP_DRIVERS, MOBILE_DRIVERS} from '../constants.js';
|
||||
import type {ExtensionConfig} from '../extension/extension-config.js';
|
||||
import {log} from '../logger.js';
|
||||
import {runExtensionCommand} from './extension.js';
|
||||
|
||||
/**
|
||||
* Subcommands of preset for setup
|
||||
|
||||
@@ -75,6 +75,11 @@ export const CACHE_DIR_RELATIVE_PATH = path.join('node_modules', '.cache', 'appi
|
||||
*/
|
||||
export const PKG_HASHFILE_RELATIVE_PATH = path.join(CACHE_DIR_RELATIVE_PATH, 'package.hash');
|
||||
|
||||
/**
|
||||
* Relative path to lockfile used when installing an extension via `appium`
|
||||
*/
|
||||
export const INSTALL_LOCKFILE_RELATIVE_PATH = path.join(CACHE_DIR_RELATIVE_PATH, '.install.lock');
|
||||
|
||||
export const EXT_SUBCOMMAND_LIST = 'list';
|
||||
export const EXT_SUBCOMMAND_INSTALL = 'install';
|
||||
export const EXT_SUBCOMMAND_UNINSTALL = 'uninstall';
|
||||
@@ -110,3 +115,8 @@ export const BIDI_EVENT_NAME = 'bidiEvent';
|
||||
* The name of the insecure feature that allows retrieving the list of active server sessions
|
||||
*/
|
||||
export const SESSION_DISCOVERY_FEATURE = 'session_discovery';
|
||||
|
||||
/**
|
||||
* The name of the insecure feature that allows CORS requests from any host
|
||||
*/
|
||||
export const CORS_FEATURE = 'cors';
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import {util} from '@appium/support';
|
||||
import type {DriverClass, DriverType, StringRecord} from '@appium/types';
|
||||
import type {ExtManifest, ExtName, ExtRecord} from 'appium/types';
|
||||
import type {ExtManifest, ExtName, ExtRecord} from 'appium/types/index.js';
|
||||
|
||||
import {DRIVER_TYPE} from '../constants';
|
||||
import {log} from '../logger';
|
||||
import type {ExtManifestProblem} from './extension-config';
|
||||
import {ExtensionConfig} from './extension-config';
|
||||
import type {Manifest} from './manifest';
|
||||
import {DRIVER_TYPE} from '../constants.js';
|
||||
import {log} from '../logger.js';
|
||||
import {ExtensionConfig} from './extension-config.js';
|
||||
import {manifestValidator} from './manifest/index.js';
|
||||
import type {ExtManifestProblem, Manifest} from './manifest/index.js';
|
||||
|
||||
export type MatchedDriver = {
|
||||
driver: DriverClass;
|
||||
@@ -87,46 +86,21 @@ export class DriverConfig extends ExtensionConfig<DriverType> {
|
||||
|
||||
protected override getConfigProblems(extManifest: ExtManifest<DriverType>, extName: string): ExtManifestProblem[] {
|
||||
void extName;
|
||||
const problems: ExtManifestProblem[] = [];
|
||||
const {platformNames, automationName} = extManifest;
|
||||
const problems = manifestValidator.getDriverManifestProblems(extManifest);
|
||||
// `?.`, not destructuring: a corrupted entry may be null/non-object (already reported above).
|
||||
const automationName = (extManifest as unknown as Record<string, unknown> | null | undefined)?.automationName;
|
||||
|
||||
if (!Array.isArray(platformNames)) {
|
||||
problems.push({
|
||||
err: 'Missing or incorrect supported platformNames list.',
|
||||
val: platformNames,
|
||||
});
|
||||
} else if (util.isEmpty(platformNames)) {
|
||||
problems.push({
|
||||
err: 'Empty platformNames list.',
|
||||
val: platformNames,
|
||||
});
|
||||
} else {
|
||||
for (const pName of platformNames) {
|
||||
if (typeof pName !== 'string') {
|
||||
problems.push({
|
||||
err: 'Incorrectly formatted platformName.',
|
||||
val: pName,
|
||||
});
|
||||
}
|
||||
// Only track actual strings — `undefined` would make every automationName-less entry a "duplicate".
|
||||
if (typeof automationName === 'string') {
|
||||
if (this.knownAutomationNames.has(automationName)) {
|
||||
problems.push({
|
||||
err: 'Multiple drivers claim support for the same automationName',
|
||||
val: automationName,
|
||||
});
|
||||
}
|
||||
this.knownAutomationNames.add(automationName);
|
||||
}
|
||||
|
||||
if (typeof automationName !== 'string') {
|
||||
problems.push({
|
||||
err: 'Missing or incorrect automationName',
|
||||
val: automationName,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.knownAutomationNames.has(automationName as string)) {
|
||||
problems.push({
|
||||
err: 'Multiple drivers claim support for the same automationName',
|
||||
val: automationName,
|
||||
});
|
||||
}
|
||||
|
||||
this.knownAutomationNames.add(automationName as string);
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {createRequire} from 'node:module';
|
||||
import path from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
|
||||
import {fs, system, util} from '@appium/support';
|
||||
import type {ExtensionType, StringRecord} from '@appium/types';
|
||||
import type {SchemaObject} from 'ajv';
|
||||
import type {ExtClass, ExtManifest, ExtName, ExtRecord, InstallType} from 'appium/types';
|
||||
import type {ExtClass, ExtManifest, ExtName, ExtRecord} from 'appium/types/index.js';
|
||||
import {satisfies} from 'semver';
|
||||
|
||||
import type {
|
||||
@@ -12,51 +12,19 @@ import type {
|
||||
ExtensionListData,
|
||||
InstalledExtensionListData,
|
||||
ExtensionCliCommand,
|
||||
} from '../cli/extension-command';
|
||||
import {APPIUM_VER} from '../helpers/build';
|
||||
import {log} from '../logger';
|
||||
import {ALLOWED_SCHEMA_EXTENSIONS, isAllowedSchemaFileExtension, registerSchema} from '../schema/schema';
|
||||
import {capitalize, resolveFrom} from '../utils';
|
||||
import type {Manifest} from './manifest';
|
||||
} from '../cli/extension-command.js';
|
||||
import {APPIUM_VER} from '../helpers/build.js';
|
||||
import {log} from '../logger.js';
|
||||
import type {SchemaObject} from '../schema/ajv.js';
|
||||
import {ALLOWED_SCHEMA_EXTENSIONS, isAllowedSchemaFileExtension, registerSchema} from '../schema/schema.js';
|
||||
import {capitalize, resolveFrom} from '../utils/index.js';
|
||||
import {INSTALL_TYPES, manifestValidator} from './manifest/index.js';
|
||||
import type {ExtManifestProblem, Manifest} from './manifest/index.js';
|
||||
|
||||
const DEFAULT_ENTRY_POINT = 'index.js';
|
||||
/**
|
||||
* "npm" install type
|
||||
* Used when extension was installed by npm package name
|
||||
* @remarks _All_ extensions are installed _by_ `npm`, but only this one means the package name was
|
||||
* used to specify it
|
||||
*/
|
||||
export const INSTALL_TYPE_NPM = 'npm';
|
||||
/**
|
||||
* "local" install type
|
||||
* Used when extension was installed from a local path
|
||||
*/
|
||||
export const INSTALL_TYPE_LOCAL = 'local';
|
||||
/**
|
||||
* "github" install type
|
||||
* Used when extension was installed via GitHub URL
|
||||
*/
|
||||
export const INSTALL_TYPE_GITHUB = 'github';
|
||||
/**
|
||||
* "git" install type
|
||||
* Used when extensions was installed via Git URL
|
||||
*/
|
||||
export const INSTALL_TYPE_GIT = 'git';
|
||||
/**
|
||||
* "dev" install type
|
||||
* Used when automatically detected as a working copy
|
||||
*/
|
||||
export const INSTALL_TYPE_DEV = 'dev';
|
||||
|
||||
export const INSTALL_TYPES = new Set<InstallType>([
|
||||
INSTALL_TYPE_GIT,
|
||||
INSTALL_TYPE_GITHUB,
|
||||
INSTALL_TYPE_LOCAL,
|
||||
INSTALL_TYPE_NPM,
|
||||
INSTALL_TYPE_DEV,
|
||||
]);
|
||||
|
||||
export type ExtManifestProblem = {err: string; val: unknown};
|
||||
// Counter for `APPIUM_RELOAD_EXTENSIONS` cache-busting; `Date.now()` alone can collide when two
|
||||
// reloads happen within the same millisecond, which would serve the stale cached module.
|
||||
let reloadCounter = 0;
|
||||
|
||||
export type ExtManifestWithSchema<E extends ExtensionType> = ExtManifest<E> & {
|
||||
schema: NonNullable<ExtManifest<E>['schema']>;
|
||||
@@ -64,6 +32,31 @@ export type ExtManifestWithSchema<E extends ExtensionType> = ExtManifest<E> & {
|
||||
|
||||
export type ExtensionConfigMutationOpts = {write?: boolean};
|
||||
|
||||
/**
|
||||
* Results of the most recent {@linkcode ExtensionConfig.validate} call, not yet rendered anywhere.
|
||||
* `validate()` only computes this; call {@linkcode ExtensionConfig.printValidationSummary} to display it,
|
||||
* so the caller can pick a sink appropriate to its own output context (e.g. a JSON-mode-aware CLI
|
||||
* console, or the server's Winston-backed logger).
|
||||
*/
|
||||
interface ExtensionValidationSummary {
|
||||
/** Number of extensions considered; used only for pluralizing the summary's lead-in line. */
|
||||
checkedCount: number;
|
||||
errorSummaries: string[];
|
||||
warningSummaries: string[];
|
||||
}
|
||||
|
||||
/** Minimal logger shape needed to render a {@linkcode ExtensionValidationSummary}. */
|
||||
export type ValidationSummarySink = {
|
||||
warn(message?: string, ...args: any[]): void;
|
||||
error(message?: string, ...args: any[]): void;
|
||||
};
|
||||
|
||||
const EMPTY_VALIDATION_SUMMARY: ExtensionValidationSummary = {
|
||||
checkedCount: 0,
|
||||
errorSummaries: [],
|
||||
warningSummaries: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared configuration and validation for installed Appium extensions (drivers or plugins).
|
||||
* Subclasses fix the extension kind; do not instantiate this class directly.
|
||||
@@ -71,13 +64,21 @@ export type ExtensionConfigMutationOpts = {write?: boolean};
|
||||
export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
readonly extensionType: ExtType;
|
||||
readonly manifest: Manifest;
|
||||
installedExtensions: ExtRecord<ExtType>;
|
||||
#listDataCache: ExtensionList<ExtType> | undefined;
|
||||
/** Populated by {@linkcode ExtensionConfig.validate}; see {@linkcode ExtensionValidationSummary}. */
|
||||
private validationSummary: ExtensionValidationSummary = EMPTY_VALIDATION_SUMMARY;
|
||||
private listDataCache: ExtensionList<ExtType> | undefined;
|
||||
|
||||
protected constructor(extensionType: ExtType, manifest: Manifest) {
|
||||
this.extensionType = extensionType;
|
||||
this.manifest = manifest;
|
||||
this.installedExtensions = manifest.getExtensionData(extensionType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live installed-extension map for this extension kind (same object the manifest holds).
|
||||
* Recomputed on every access so it stays in sync across a {@link Manifest.read} reload.
|
||||
*/
|
||||
get installedExtensions(): ExtRecord<ExtType> {
|
||||
return this.manifest.getExtensionData(this.extensionType);
|
||||
}
|
||||
|
||||
/** Path to `extensions.yaml` after the manifest has been read; otherwise undefined. */
|
||||
@@ -119,7 +120,16 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
let moduleObject: any;
|
||||
if (typeof argSchemaPath === 'string') {
|
||||
const schemaPath = await resolveFrom(appiumHome, path.join(pkgName, argSchemaPath));
|
||||
moduleObject = require(schemaPath);
|
||||
if (path.extname(schemaPath) === '.json') {
|
||||
// `import()` of JSON needs an import attribute Node versions disagree on the
|
||||
// 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;
|
||||
const mod = (await import(importPath)) as Record<string, any>;
|
||||
moduleObject = 'default' in mod ? mod.default : mod;
|
||||
}
|
||||
} else {
|
||||
moduleObject = argSchemaPath;
|
||||
}
|
||||
@@ -296,7 +306,23 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
const [reqPath, mainClass] = await this._resolveExtension(extName);
|
||||
log.debug(`Requiring ${this.extensionType} at ${reqPath}`);
|
||||
// https://github.com/nodejs/node/issues/31710
|
||||
const importPath = system.isWindows() ? pathToFileURL(reqPath).href : reqPath;
|
||||
let importPath = system.isWindows() ? pathToFileURL(reqPath).href : reqPath;
|
||||
// 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
|
||||
// resolved filename and ignores the query string appended below — evict it from
|
||||
// `require.cache` directly so it's actually re-evaluated. (No-op for a genuinely ESM
|
||||
// extension, since it was never in `require.cache` to begin with.)
|
||||
const req = createRequire(import.meta.url);
|
||||
const realEntryPath = await fs.realpath(reqPath);
|
||||
if (req.cache[realEntryPath]) {
|
||||
delete req.cache[realEntryPath];
|
||||
}
|
||||
// For an ESM extension, there's no public API to evict a module from ESM's registry, so
|
||||
// force a fresh copy via a unique specifier instead.
|
||||
importPath += `?reload=${reloadCounter++}`;
|
||||
log.debug(`Reloading ${this.extensionType} at ${reqPath}`);
|
||||
}
|
||||
const mod = (await import(importPath)) as Record<string, ExtClass<ExtType>>;
|
||||
const MainClass = mod[mainClass];
|
||||
if (!MainClass) {
|
||||
@@ -333,8 +359,8 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates all entries in `exts`, logs summaries, and removes keys that have blocking errors.
|
||||
* Intended for subclasses’ `validate` implementation.
|
||||
* Validates all entries in `exts`, records a summary (see {@linkcode ExtensionConfig.printValidationSummary}),
|
||||
* and removes keys that have blocking errors. Intended for subclasses’ `validate` implementation.
|
||||
*/
|
||||
protected async _validate(exts: ExtRecord<ExtType>): Promise<ExtRecord<ExtType>> {
|
||||
const errorMap = new Map<string, ExtManifestProblem[]>();
|
||||
@@ -353,47 +379,61 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
}
|
||||
|
||||
const {errorSummaries, warningSummaries} = this.getValidationResultSummaries(errorMap, warningMap);
|
||||
this.validationSummary = {checkedCount: errorMap.size, errorSummaries, warningSummaries};
|
||||
return exts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the summary from the most recent {@linkcode ExtensionConfig.validate} call via `sink`,
|
||||
* then clears it so a later call doesn't re-print the same summary. Errors take precedence:
|
||||
* warnings are only shown when there were no errors. No-op if there's nothing to report.
|
||||
*
|
||||
* @param sink - Where to write the summary; e.g. a JSON-mode-aware CLI console for extension
|
||||
* subcommands, or the server's own (by then Winston-backed) logger once it has taken over.
|
||||
*/
|
||||
printValidationSummary(sink: ValidationSummarySink): void {
|
||||
const {checkedCount, errorSummaries, warningSummaries} = this.validationSummary;
|
||||
this.validationSummary = EMPTY_VALIDATION_SUMMARY;
|
||||
|
||||
if (!util.isEmpty(errorSummaries)) {
|
||||
log.error(
|
||||
sink.error(
|
||||
`Appium encountered ${util.pluralize(
|
||||
'error',
|
||||
errorMap.size,
|
||||
checkedCount,
|
||||
true,
|
||||
)} while validating ${this.extensionType}s found in manifest ${this.manifestPath}`,
|
||||
);
|
||||
for (const summary of errorSummaries) {
|
||||
log.error(summary);
|
||||
sink.error(summary);
|
||||
}
|
||||
} else if (!util.isEmpty(warningSummaries)) {
|
||||
// only display warnings if there are no errors!
|
||||
log.warn(
|
||||
sink.warn(
|
||||
`Appium encountered ${util.pluralize(
|
||||
'warning',
|
||||
warningMap.size,
|
||||
checkedCount,
|
||||
true,
|
||||
)} while validating ${this.extensionType}s found in manifest ${this.manifestPath}`,
|
||||
);
|
||||
for (const summary of warningSummaries) {
|
||||
log.warn(summary);
|
||||
sink.warn(summary);
|
||||
}
|
||||
}
|
||||
return exts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches `appium driver|plugin list`-style data via the CLI command class; result is cached.
|
||||
*/
|
||||
protected async getListData(): Promise<ExtensionList<ExtType>> {
|
||||
if (this.#listDataCache) {
|
||||
return this.#listDataCache;
|
||||
if (this.listDataCache) {
|
||||
return this.listDataCache;
|
||||
}
|
||||
// Import here to avoid circular dependency with cli/extension
|
||||
const {commandClasses} = await import('../cli/extension.js');
|
||||
const CommandClass = (commandClasses as StringRecord)[this.extensionType];
|
||||
const cmd = new CommandClass({config: this, json: true}) as ExtensionCliCommand<ExtType>;
|
||||
const listData = await cmd.list({showInstalled: true, showUpdates: true});
|
||||
this.#listDataCache = listData;
|
||||
this.listDataCache = listData;
|
||||
return listData;
|
||||
}
|
||||
|
||||
@@ -513,31 +553,7 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
/** Blocking issues for required manifest fields shared by all extensions (version, package name, main class). */
|
||||
protected getGenericConfigProblems(extManifest: ExtManifest<ExtType>, extName: string): ExtManifestProblem[] {
|
||||
void extName;
|
||||
const {version, pkgName, mainClass} = extManifest;
|
||||
const problems: ExtManifestProblem[] = [];
|
||||
|
||||
if (typeof version !== 'string') {
|
||||
problems.push({
|
||||
err: `Invalid or missing \`version\` field in my \`package.json\` and/or \`extensions.yaml\` (must be a string)`,
|
||||
val: version,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof pkgName !== 'string') {
|
||||
problems.push({
|
||||
err: `Invalid or missing \`name\` field in my \`package.json\` and/or \`extensions.yaml\` (must be a string)`,
|
||||
val: pkgName,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof mainClass !== 'string') {
|
||||
problems.push({
|
||||
err: `Invalid or missing \`appium.mainClass\` field in my \`package.json\` and/or \`mainClass\` field in \`extensions.yaml\` (must be a string)`,
|
||||
val: mainClass,
|
||||
});
|
||||
}
|
||||
|
||||
return problems;
|
||||
return manifestValidator.getCommonManifestProblems(extManifest);
|
||||
}
|
||||
|
||||
/** Driver- or plugin-specific blocking validation; override in subclasses when needed. */
|
||||
@@ -578,11 +594,6 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
`Assumed entry point: '${entryPointFullPath}'`,
|
||||
);
|
||||
}
|
||||
// note: this will only reload the entry point
|
||||
if (process.env.APPIUM_RELOAD_EXTENSIONS && require.cache[entryPointFullPath]) {
|
||||
log.debug(`Removing ${entryPointFullPath} from require cache`);
|
||||
delete require.cache[entryPointFullPath];
|
||||
}
|
||||
return [entryPointFullPath, mainClass];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {timing, util} from '@appium/support';
|
||||
import type {DriverClass, ExtensionType, PluginClass} from '@appium/types';
|
||||
import type {ExtClass} from 'appium/types';
|
||||
import type {ExtClass} from 'appium/types/index.js';
|
||||
import {asyncmap} from 'asyncbox';
|
||||
|
||||
import {USE_ALL_PLUGINS} from '../constants';
|
||||
import {log} from '../logger';
|
||||
import {zip} from '../utils';
|
||||
import {DriverConfig} from './driver-config';
|
||||
import {Manifest} from './manifest';
|
||||
import {PluginConfig} from './plugin-config';
|
||||
import {USE_ALL_PLUGINS} from '../constants.js';
|
||||
import {log} from '../logger.js';
|
||||
import {withManifestLock, zip} from '../utils/index.js';
|
||||
import {DriverConfig} from './driver-config.js';
|
||||
import {Manifest} from './manifest/index.js';
|
||||
import {PluginConfig} from './plugin-config.js';
|
||||
|
||||
export type ExtensionConfigs = {
|
||||
driverConfig: DriverConfig;
|
||||
@@ -18,6 +18,22 @@ export type ExtensionConfigs = {
|
||||
export type PluginNameMap = Map<PluginClass, string>;
|
||||
export type DriverNameMap = Map<DriverClass, string>;
|
||||
|
||||
/**
|
||||
* Re-reads `manifest` from disk and revalidates whichever `DriverConfig`/`PluginConfig`
|
||||
* singletons already exist for it, so their derived state (installed extensions, pending
|
||||
* validation summary, {@link DriverConfig}'s duplicate-`automationName` tracking) reflects the
|
||||
* freshly-read data rather than a stale pre-reload snapshot.
|
||||
*
|
||||
* Callers must run this under {@link withManifestLock} -- both because `manifest.read()` may
|
||||
* itself write the manifest (initialization, migration, or sync with installed packages), and
|
||||
* because anything a caller does afterward that depends on the result staying current (e.g.
|
||||
* writing the manifest back out) needs to stay in the same critical section.
|
||||
*/
|
||||
export async function reloadManifest(manifest: Manifest): Promise<void> {
|
||||
await manifest.read();
|
||||
await Promise.all([DriverConfig.getInstance(manifest)?.validate(), PluginConfig.getInstance(manifest)?.validate()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads extensions and creates `ExtensionConfig` instances.
|
||||
*
|
||||
@@ -25,15 +41,15 @@ export type DriverNameMap = Map<DriverClass, string>;
|
||||
* - Using the parsed extension data, creates/gets the `ExtensionConfig` subclass instances
|
||||
* - Returns these instances
|
||||
*
|
||||
* If `appiumHome` is needed, use `resolveAppiumHome` from the `env` module in `@appium/support`.
|
||||
* If `appiumHome` is needed, use `resolveAppiumHome` from `../utils/index.js`.
|
||||
*/
|
||||
export async function loadExtensions(appiumHome: string): Promise<ExtensionConfigs> {
|
||||
const manifest = Manifest.getInstance(appiumHome);
|
||||
await manifest.read();
|
||||
const driverConfig = DriverConfig.getInstance(manifest) ?? DriverConfig.create(manifest);
|
||||
const pluginConfig = PluginConfig.getInstance(manifest) ?? PluginConfig.create(manifest);
|
||||
|
||||
await Promise.all([driverConfig.validate(), pluginConfig.validate()]);
|
||||
await withManifestLock(appiumHome, () => reloadManifest(manifest));
|
||||
|
||||
return {driverConfig, pluginConfig};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export {Manifest} from './manifest.js';
|
||||
export {
|
||||
INSTALL_TYPE_DEV,
|
||||
INSTALL_TYPE_GIT,
|
||||
INSTALL_TYPE_GITHUB,
|
||||
INSTALL_TYPE_LOCAL,
|
||||
INSTALL_TYPE_NPM,
|
||||
INSTALL_TYPES,
|
||||
} from './install-types.js';
|
||||
export type {EnvelopeValidationResult, ExtManifestProblem} from './validator.js';
|
||||
export {manifestValidator} from './validator.js';
|
||||
@@ -0,0 +1,37 @@
|
||||
import type {InstallType} from 'appium/types/index.js';
|
||||
|
||||
/**
|
||||
* "npm" install type
|
||||
* Used when extension was installed by npm package name
|
||||
* @remarks _All_ extensions are installed _by_ `npm`, but only this one means the package name was
|
||||
* used to specify it
|
||||
*/
|
||||
export const INSTALL_TYPE_NPM = 'npm';
|
||||
/**
|
||||
* "local" install type
|
||||
* Used when extension was installed from a local path
|
||||
*/
|
||||
export const INSTALL_TYPE_LOCAL = 'local';
|
||||
/**
|
||||
* "github" install type
|
||||
* Used when extension was installed via GitHub URL
|
||||
*/
|
||||
export const INSTALL_TYPE_GITHUB = 'github';
|
||||
/**
|
||||
* "git" install type
|
||||
* Used when extensions was installed via Git URL
|
||||
*/
|
||||
export const INSTALL_TYPE_GIT = 'git';
|
||||
/**
|
||||
* "dev" install type
|
||||
* Used when automatically detected as a working copy
|
||||
*/
|
||||
export const INSTALL_TYPE_DEV = 'dev';
|
||||
|
||||
export const INSTALL_TYPES = new Set<InstallType>([
|
||||
INSTALL_TYPE_GIT,
|
||||
INSTALL_TYPE_GITHUB,
|
||||
INSTALL_TYPE_LOCAL,
|
||||
INSTALL_TYPE_NPM,
|
||||
INSTALL_TYPE_DEV,
|
||||
]);
|
||||
+76
-12
@@ -1,14 +1,23 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import {env, fs, util} from '@appium/support';
|
||||
import {fs, util} from '@appium/support';
|
||||
import type {DriverType, ExtensionType, PluginType} from '@appium/types';
|
||||
import type {ExtManifest, ExtPackageJson, ExtRecord, InternalMetadata, ManifestData} from 'appium/types';
|
||||
import type {ExtManifest, ExtPackageJson, ExtRecord, InternalMetadata, ManifestData} from 'appium/types/index.js';
|
||||
import {asyncmap} from 'asyncbox';
|
||||
import * as YAML from 'yaml';
|
||||
|
||||
import {CURRENT_SCHEMA_REV, DRIVER_TYPE, PLUGIN_TYPE} from '../constants';
|
||||
import {packageDidChange} from '../utils';
|
||||
import {INSTALL_TYPE_DEV, INSTALL_TYPE_NPM} from './extension-config';
|
||||
import {migrate} from './manifest-migrations';
|
||||
import {CURRENT_SCHEMA_REV, DRIVER_TYPE, PLUGIN_TYPE} from '../../constants.js';
|
||||
import {log} from '../../logger.js';
|
||||
import {
|
||||
hasAppiumDependency as checkHasAppiumDependency,
|
||||
packageDidChange,
|
||||
resolveManifestPath,
|
||||
} from '../../utils/index.js';
|
||||
import {INSTALL_TYPE_DEV, INSTALL_TYPE_NPM} from './install-types.js';
|
||||
import {migrate} from './migrations.js';
|
||||
import {manifestValidator} from './validator.js';
|
||||
|
||||
const MAX_CONCURRENT_FS_TASKS = 10;
|
||||
|
||||
const CONFIG_DATA_DRIVER_KEY = `${DRIVER_TYPE}s` as const;
|
||||
const CONFIG_DATA_PLUGIN_KEY = `${PLUGIN_TYPE}s` as const;
|
||||
@@ -122,13 +131,25 @@ export class Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldWrite) {
|
||||
const {valid, errors} = manifestValidator.validateManifestEnvelope(data);
|
||||
if (!valid) {
|
||||
log.warn(
|
||||
`Appium had trouble validating the extension installation cache file (${manifestPathResolved}); ` +
|
||||
`it will be reset. Specific error(s): ${manifestValidator.describeValidationErrors(errors)}`,
|
||||
);
|
||||
data = structuredClone(INITIAL_MANIFEST_DATA) as ManifestData;
|
||||
shouldWrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.#data = data;
|
||||
|
||||
if (!shouldWrite && (data.schemaRev ?? 0) < CURRENT_SCHEMA_REV) {
|
||||
shouldWrite = await migrate(this);
|
||||
}
|
||||
|
||||
const hasAppiumDependency = await env.hasAppiumDependency(this.appiumHome);
|
||||
const hasAppiumDependency = await checkHasAppiumDependency(this.appiumHome);
|
||||
|
||||
if (shouldWrite || (hasAppiumDependency && (await packageDidChange(this.appiumHome)))) {
|
||||
shouldWrite = (await this.syncWithInstalledExtensions(hasAppiumDependency)) || shouldWrite;
|
||||
@@ -210,10 +231,7 @@ export class Manifest {
|
||||
|
||||
const queue: Promise<void>[] = [onMatch(path.join(this.#appiumHome, 'package.json'), true)];
|
||||
|
||||
const filepaths = await fs.glob('node_modules/{*,@*/*}/package.json', {
|
||||
cwd: this.#appiumHome,
|
||||
absolute: true,
|
||||
});
|
||||
const filepaths = await findNodeModulesPackageJsons(path.join(this.#appiumHome, 'node_modules'));
|
||||
for (const filepath of filepaths) {
|
||||
queue.push(onMatch(filepath));
|
||||
}
|
||||
@@ -324,7 +342,7 @@ export class Manifest {
|
||||
|
||||
async #setManifestPath(): Promise<string> {
|
||||
if (!this.#manifestPath) {
|
||||
const resolved = await env.resolveManifestPath(this.#appiumHome);
|
||||
const resolved = await resolveManifestPath(this.#appiumHome);
|
||||
this.#manifestPath = resolved;
|
||||
|
||||
if (path.relative(this.#appiumHome, resolved).startsWith('.')) {
|
||||
@@ -338,6 +356,52 @@ export class Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds `package.json` paths for packages directly under `node_modules` (and one level deeper for
|
||||
* scoped `@foo/bar` packages). Unlike `fs.glob`, this follows symlinks, which is how `npm install
|
||||
* <local-path>` (and `npm link`) install packages.
|
||||
*/
|
||||
async function findNodeModulesPackageJsons(nodeModulesDir: string): Promise<string[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(nodeModulesDir);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const pkgJsonPathsByEntry = await asyncmap(
|
||||
entries.filter((entry) => !entry.startsWith('.')),
|
||||
async (entry): Promise<string[]> => {
|
||||
const entryPath = path.join(nodeModulesDir, entry);
|
||||
if (!entry.startsWith('@')) {
|
||||
const pkgJsonPath = path.join(entryPath, 'package.json');
|
||||
return (await fs.exists(pkgJsonPath)) ? [pkgJsonPath] : [];
|
||||
}
|
||||
|
||||
let scopedEntries: string[];
|
||||
try {
|
||||
scopedEntries = await fs.readdir(entryPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const scopedPkgJsonPaths = await asyncmap(
|
||||
scopedEntries.filter((scopedEntry) => !scopedEntry.startsWith('.')),
|
||||
async (scopedEntry) => {
|
||||
const pkgJsonPath = path.join(entryPath, scopedEntry, 'package.json');
|
||||
return (await fs.exists(pkgJsonPath)) ? pkgJsonPath : null;
|
||||
},
|
||||
{concurrency: MAX_CONCURRENT_FS_TASKS},
|
||||
);
|
||||
return scopedPkgJsonPaths.filter((pkgJsonPath) => pkgJsonPath !== null);
|
||||
},
|
||||
{concurrency: MAX_CONCURRENT_FS_TASKS},
|
||||
);
|
||||
return pkgJsonPathsByEntry.flat();
|
||||
}
|
||||
|
||||
function isExtension(value: unknown): value is ExtPackageJson<ExtensionType> {
|
||||
return (
|
||||
util.isPlainObject(value) &&
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import type {ManifestDataVersions} from 'appium/types';
|
||||
import type {ManifestDataVersions} from 'appium/types/index.js';
|
||||
|
||||
import {CURRENT_SCHEMA_REV, DRIVER_TYPE, PLUGIN_TYPE} from '../constants';
|
||||
import {log} from '../logger';
|
||||
import type {Manifest} from './manifest';
|
||||
import {CURRENT_SCHEMA_REV, DRIVER_TYPE, PLUGIN_TYPE} from '../../constants.js';
|
||||
import {log} from '../../logger.js';
|
||||
import type {Manifest} from './manifest.js';
|
||||
|
||||
const SCHEMA_REV_3 = 3;
|
||||
const SCHEMA_REV_4 = 4;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {SchemaObject} from '../../schema/ajv.js';
|
||||
|
||||
/**
|
||||
* Shallow shape of the parsed `extensions.yaml` document. Deliberately permissive about the
|
||||
* shape of individual `drivers`/`plugins` entries (see {@link commonExtManifestProblemsSchema}
|
||||
* and {@link driverExtManifestProblemsSchema}) so that old-but-valid manifest revisions are
|
||||
* never rejected here; that's `Manifest.read()`'s job before per-extension validation runs.
|
||||
*/
|
||||
export const manifestEnvelopeSchema: SchemaObject = {
|
||||
type: 'object',
|
||||
required: ['drivers', 'plugins'],
|
||||
properties: {
|
||||
drivers: {type: 'object'},
|
||||
plugins: {type: 'object'},
|
||||
schemaRev: {type: 'integer'},
|
||||
},
|
||||
};
|
||||
|
||||
/** Fields required of every extension manifest entry, regardless of driver/plugin kind. */
|
||||
export const commonExtManifestProblemsSchema: SchemaObject = {
|
||||
type: 'object',
|
||||
required: ['version', 'pkgName', 'mainClass'],
|
||||
properties: {
|
||||
version: {type: 'string'},
|
||||
pkgName: {type: 'string'},
|
||||
mainClass: {type: 'string'},
|
||||
},
|
||||
};
|
||||
|
||||
/** Additional fields required of driver manifest entries. */
|
||||
export const driverExtManifestProblemsSchema: SchemaObject = {
|
||||
type: 'object',
|
||||
required: ['automationName', 'platformNames'],
|
||||
properties: {
|
||||
automationName: {type: 'string'},
|
||||
platformNames: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {type: 'string'},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import {Ajv} from '../../schema/ajv.js';
|
||||
import type {ErrorObject, ValidateFunction} from '../../schema/ajv.js';
|
||||
import {commonExtManifestProblemsSchema, driverExtManifestProblemsSchema, manifestEnvelopeSchema} from './schema.js';
|
||||
|
||||
export interface EnvelopeValidationResult {
|
||||
valid: boolean;
|
||||
errors: ErrorObject[];
|
||||
}
|
||||
|
||||
export type ExtManifestProblem = {err: string; val: unknown};
|
||||
|
||||
/**
|
||||
* Top-level manifest field an ajv error is about. For `required` errors, ajv reports the missing
|
||||
* field in `params.missingProperty` (since `instancePath` points at the object that's missing it,
|
||||
* not the field itself); for every other keyword, `instancePath` points at the offending field
|
||||
* directly, so its first segment is the field name (ignoring any array index after it, e.g. the
|
||||
* `0` in `/platformNames/0`).
|
||||
*/
|
||||
function topLevelProp(error: ErrorObject): string | undefined {
|
||||
if (error.keyword === 'required') {
|
||||
return error.params.missingProperty as string;
|
||||
}
|
||||
return error.instancePath.split('/').filter(Boolean)[0];
|
||||
}
|
||||
|
||||
/** Whether `errors` includes a failure of the root value itself (e.g. a string/array/null instead of an object). */
|
||||
function hasRootTypeError(errors: ErrorObject[]): boolean {
|
||||
return errors.some((error) => error.instancePath === '' && error.keyword === 'type');
|
||||
}
|
||||
|
||||
const MISSING_PLATFORM_NAMES_ERR = 'Missing or incorrect supported platformNames list.';
|
||||
const MISSING_AUTOMATION_NAME_ERR = 'Missing or incorrect automationName';
|
||||
|
||||
/** Validates parsed `extensions.yaml` and individual extension manifest entries via a dedicated Ajv instance. */
|
||||
class ManifestValidator {
|
||||
private readonly validateEnvelope: ValidateFunction;
|
||||
private readonly validateCommonProblems: ValidateFunction;
|
||||
private readonly validateDriverProblems: ValidateFunction;
|
||||
|
||||
constructor() {
|
||||
// Dedicated instance: manifest validation runs before `AppiumSchema` (lib/schema/schema.ts) is
|
||||
// finalized, so that singleton can't be reused here. No `format` keyword, so no ajv-formats.
|
||||
const ajv = new Ajv({allErrors: true});
|
||||
this.validateEnvelope = ajv.compile(manifestEnvelopeSchema);
|
||||
this.validateCommonProblems = ajv.compile(commonExtManifestProblemsSchema);
|
||||
this.validateDriverProblems = ajv.compile(driverExtManifestProblemsSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the top-level shape of a parsed `extensions.yaml` document (before migration).
|
||||
*
|
||||
* @param data - Result of `YAML.parse()` on the manifest file contents
|
||||
*/
|
||||
validateManifestEnvelope(data: unknown): EnvelopeValidationResult {
|
||||
const valid = Boolean(this.validateEnvelope(data));
|
||||
return {valid, errors: valid ? [] : [...(this.validateEnvelope.errors ?? [])]};
|
||||
}
|
||||
|
||||
/** Human-readable summary of ajv errors, tolerating `message` being unset (it's optional on `ErrorObject`). */
|
||||
describeValidationErrors(errors: ErrorObject[]): string {
|
||||
return errors
|
||||
.map((error) => error.message ?? `${error.keyword} error at "${error.instancePath || '/'}"`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking issues for manifest fields shared by all extensions (version, package name, main class).
|
||||
*
|
||||
* @param extManifest - Manifest entry for one extension
|
||||
*/
|
||||
getCommonManifestProblems(extManifest: unknown): ExtManifestProblem[] {
|
||||
// Stored in a variable: branching on the call directly narrows the negative branch to `never`.
|
||||
const valid: boolean = this.validateCommonProblems(extManifest);
|
||||
if (valid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const errors = this.validateCommonProblems.errors ?? [];
|
||||
// A root type error means every field is missing; topLevelProp() can't attribute it to one.
|
||||
const props = hasRootTypeError(errors)
|
||||
? new Set(['version', 'pkgName', 'mainClass'])
|
||||
: new Set(errors.map(topLevelProp));
|
||||
|
||||
// Cast once (extManifest may be non-object/null/undefined), then read safely via `?.`.
|
||||
const manifest = extManifest as Record<string, unknown> | null | undefined;
|
||||
const problems: ExtManifestProblem[] = [];
|
||||
|
||||
if (props.has('version')) {
|
||||
problems.push({
|
||||
err: 'Invalid or missing `version` field in my `package.json` and/or `extensions.yaml` (must be a string)',
|
||||
val: manifest?.version,
|
||||
});
|
||||
}
|
||||
if (props.has('pkgName')) {
|
||||
problems.push({
|
||||
err: 'Invalid or missing `name` field in my `package.json` and/or `extensions.yaml` (must be a string)',
|
||||
val: manifest?.pkgName,
|
||||
});
|
||||
}
|
||||
if (props.has('mainClass')) {
|
||||
problems.push({
|
||||
err: 'Invalid or missing `appium.mainClass` field in my `package.json` and/or `mainClass` field in `extensions.yaml` (must be a string)',
|
||||
val: manifest?.mainClass,
|
||||
});
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking issues for driver-specific manifest fields (automationName, platformNames).
|
||||
* Does not include the cross-driver duplicate-`automationName` check, which needs state
|
||||
* spanning multiple extensions and lives in {@link DriverConfig.getConfigProblems} instead.
|
||||
*
|
||||
* @param extManifest - Manifest entry for one driver
|
||||
*/
|
||||
getDriverManifestProblems(extManifest: unknown): ExtManifestProblem[] {
|
||||
// See the comment in `getCommonManifestProblems` about not narrowing on the call directly.
|
||||
const valid: boolean = this.validateDriverProblems(extManifest);
|
||||
if (valid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const errors = this.validateDriverProblems.errors ?? [];
|
||||
// See the comment in `getCommonManifestProblems` about casting once and reading via `?.`.
|
||||
const manifest = extManifest as Record<string, unknown> | null | undefined;
|
||||
const platformNames = manifest?.platformNames;
|
||||
const automationName = manifest?.automationName;
|
||||
|
||||
// A root type error means both fields are missing; no per-property error to attribute either to.
|
||||
if (hasRootTypeError(errors)) {
|
||||
return [
|
||||
{err: MISSING_PLATFORM_NAMES_ERR, val: platformNames},
|
||||
{err: MISSING_AUTOMATION_NAME_ERR, val: automationName},
|
||||
];
|
||||
}
|
||||
|
||||
const problems: ExtManifestProblem[] = [];
|
||||
const platformNamesErrors = errors.filter((error) => topLevelProp(error) === 'platformNames');
|
||||
const automationNameErrors = errors.filter((error) => topLevelProp(error) === 'automationName');
|
||||
|
||||
const isMissingOrWrongType = platformNamesErrors.some(
|
||||
(error) => error.keyword === 'required' || (error.keyword === 'type' && error.instancePath === '/platformNames'),
|
||||
);
|
||||
const isEmpty = platformNamesErrors.some((error) => error.keyword === 'minItems');
|
||||
|
||||
if (isMissingOrWrongType) {
|
||||
problems.push({err: MISSING_PLATFORM_NAMES_ERR, val: platformNames});
|
||||
} else if (isEmpty) {
|
||||
problems.push({err: 'Empty platformNames list.', val: platformNames});
|
||||
} else {
|
||||
for (const error of platformNamesErrors) {
|
||||
if (error.keyword === 'type') {
|
||||
const index = Number(error.instancePath.split('/')[2]);
|
||||
problems.push({err: 'Incorrectly formatted platformName.', val: (platformNames as unknown[])[index]});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (automationNameErrors.length) {
|
||||
problems.push({err: MISSING_AUTOMATION_NAME_ERR, val: automationName});
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
}
|
||||
|
||||
export const manifestValidator = new ManifestValidator();
|
||||
@@ -1,11 +1,11 @@
|
||||
import {util} from '@appium/support';
|
||||
import type {PluginType} from '@appium/types';
|
||||
import type {ExtManifest, ExtName, ExtRecord} from 'appium/types';
|
||||
import type {ExtManifest, ExtName, ExtRecord} from 'appium/types/index.js';
|
||||
|
||||
import {PLUGIN_TYPE} from '../constants';
|
||||
import {log} from '../logger';
|
||||
import {ExtensionConfig} from './extension-config';
|
||||
import type {Manifest} from './manifest';
|
||||
import {PLUGIN_TYPE} from '../constants.js';
|
||||
import {log} from '../logger.js';
|
||||
import {ExtensionConfig} from './extension-config.js';
|
||||
import type {Manifest} from './manifest/index.js';
|
||||
|
||||
export class PluginConfig extends ExtensionConfig<PluginType> {
|
||||
private static readonly _instances = new WeakMap<Manifest, PluginConfig>();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {fs, system, util} from '@appium/support';
|
||||
import type {BuildInfo} from 'appium/types';
|
||||
import type {BuildInfo} from 'appium/types/index.js';
|
||||
import axios from 'axios';
|
||||
import {exec} from 'teen_process';
|
||||
|
||||
import {npmPackage} from '../utils';
|
||||
import {npmPackage} from '../utils/index.js';
|
||||
|
||||
export const APPIUM_VER = npmPackage.version;
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function getGitRev(useGithubApiFallback = false): Promise<string |
|
||||
if (fullGitPath) {
|
||||
try {
|
||||
const {stdout} = await exec(fullGitPath, ['rev-parse', 'HEAD'], {
|
||||
cwd: __dirname,
|
||||
cwd: import.meta.dirname,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch {}
|
||||
@@ -58,7 +58,7 @@ async function getGitTimestamp(commitSha: string, useGithubApiFallback = false):
|
||||
if (fullGitPath) {
|
||||
try {
|
||||
const {stdout} = await exec(fullGitPath, ['show', '-s', '--format=%ci', commitSha], {
|
||||
cwd: __dirname,
|
||||
cwd: import.meta.dirname,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch {}
|
||||
|
||||
@@ -2,8 +2,8 @@ import {errors, isW3cCaps, processCapabilities, STANDARD_CAPS} from '@appium/bas
|
||||
import {util} from '@appium/support';
|
||||
import type {BaseDriverCapConstraints, Capabilities, Constraints, NSCapabilities, W3CCapabilities} from '@appium/types';
|
||||
|
||||
import {log as logger} from '../logger';
|
||||
import {mapKeys} from '../utils';
|
||||
import {log as logger} from '../logger.js';
|
||||
import {mapKeys} from '../utils/index.js';
|
||||
|
||||
const W3C_APPIUM_PREFIX = 'appium';
|
||||
const STANDARD_CAPS_LOWERCASE = new Set([...STANDARD_CAPS].map((cap) => cap.toLowerCase()));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {util} from '@appium/support';
|
||||
import type {ExternalDriver} from '@appium/types';
|
||||
|
||||
import type {AppiumDriver} from './appium';
|
||||
import {log as logger} from './logger';
|
||||
import type {AppiumDriver} from './appium.js';
|
||||
import {log as logger} from './logger.js';
|
||||
|
||||
const ALL_DRIVERS_MATCH = '*';
|
||||
const FEATURE_NAME_SEPARATOR = ':';
|
||||
@@ -48,6 +48,9 @@ export function configureGlobalFeatures(this: AppiumDriver) {
|
||||
* @param driverName
|
||||
*/
|
||||
export function configureDriverFeatures(this: AppiumDriver, driver: ExternalDriver, driverName: string) {
|
||||
// the docs tell users to scope a feature with the driver's automationName, which is also what
|
||||
// the driver itself compares against in `isFeatureEnabled`
|
||||
const automationName = this.driverConfig?.installedExtensions?.[driverName]?.automationName;
|
||||
if (this.relaxedSecurityEnabled) {
|
||||
this.log.info(
|
||||
`Enabling relaxed security for this session as per the server configuration. ` +
|
||||
@@ -55,13 +58,13 @@ export function configureDriverFeatures(this: AppiumDriver, driver: ExternalDriv
|
||||
);
|
||||
driver.relaxedSecurityEnabled = true;
|
||||
}
|
||||
const allowedDriverFeatures = filterInsecureFeatures(this.allowInsecure, driverName);
|
||||
const allowedDriverFeatures = filterInsecureFeatures(this.allowInsecure, driverName, automationName);
|
||||
if (!util.isEmpty(allowedDriverFeatures)) {
|
||||
this.log.info('Explicitly enabling insecure features for this session ' + 'as per the server configuration:');
|
||||
allowedDriverFeatures.forEach((a) => this.log.info(` ${a}`));
|
||||
driver.allowInsecure = allowedDriverFeatures;
|
||||
}
|
||||
const deniedDriverFeatures = filterInsecureFeatures(this.denyInsecure, driverName);
|
||||
const deniedDriverFeatures = filterInsecureFeatures(this.denyInsecure, driverName, automationName);
|
||||
if (util.isEmpty(deniedDriverFeatures)) {
|
||||
return;
|
||||
}
|
||||
@@ -97,20 +100,29 @@ function validateFeatures(features: string[]): string[] {
|
||||
|
||||
/**
|
||||
* Filters the list of insecure features to only those that are
|
||||
* applicable to the given driver name.
|
||||
* applicable to the given driver.
|
||||
* Assumes that all feature names have already been validated
|
||||
*
|
||||
* The prefix may be the driver's install name or its automationName, which is the one the
|
||||
* security guide documents. Both are compared case-insensitively, like the prefix check
|
||||
* `isFeatureEnabled` does on the driver side.
|
||||
*
|
||||
* @param features
|
||||
* @param driverName
|
||||
* @param automationName
|
||||
*/
|
||||
function filterInsecureFeatures(features: string[], driverName: string = ALL_DRIVERS_MATCH): string[] {
|
||||
function filterInsecureFeatures(features: string[], driverName?: string, automationName?: string): string[] {
|
||||
const acceptedPrefixes = new Set(
|
||||
[ALL_DRIVERS_MATCH, driverName, automationName]
|
||||
.filter((name): name is string => Boolean(name))
|
||||
.map((name) => name.toLowerCase()),
|
||||
);
|
||||
const filterFn = (fullName: string) => {
|
||||
const separatorPos = fullName.indexOf(FEATURE_NAME_SEPARATOR);
|
||||
if (separatorPos <= 0) {
|
||||
return false;
|
||||
}
|
||||
const automationName = fullName.substring(0, separatorPos);
|
||||
return [driverName, ALL_DRIVERS_MATCH].includes(automationName);
|
||||
return acceptedPrefixes.has(fullName.substring(0, separatorPos).toLowerCase());
|
||||
};
|
||||
return features.filter(filterFn);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import type {
|
||||
RestMethodsToCommandsMap,
|
||||
} from '@appium/types';
|
||||
|
||||
import type {AppiumDriver} from './appium';
|
||||
import {mapValues} from './utils';
|
||||
import type {AppiumDriver} from './appium.js';
|
||||
import {mapValues} from './utils/index.js';
|
||||
|
||||
/**
|
||||
* Returns available REST and BiDi commands for base, driver and plugins.
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type {MessageObject} from '@appium/logger';
|
||||
import globalLog from '@appium/logger';
|
||||
import {log as globalLog, type MessageObject} from '@appium/logger';
|
||||
import {fs, util} from '@appium/support';
|
||||
import type {ParsedArgs} from 'appium/types';
|
||||
import type {ParsedArgs} from 'appium/types/index.js';
|
||||
import {LRUCache} from 'lru-cache';
|
||||
import type {Logform, Logger} from 'winston';
|
||||
import {createLogger, format, transports} from 'winston';
|
||||
import type Transport from 'winston-transport';
|
||||
|
||||
import {adler32} from './utils';
|
||||
import {adler32} from './utils/index.js';
|
||||
|
||||
const LEVELS_MAP = {
|
||||
debug: 4,
|
||||
@@ -32,6 +31,13 @@ const TO_WINSTON_LEVELS_MAP = {
|
||||
} as const;
|
||||
const COLOR_CODE_PATTERN = /\u001b\[(\d+(;\d+)*)?m/g; // eslint-disable-line no-control-regex
|
||||
|
||||
const HTTPS_PROTOCOL = 'https:';
|
||||
const SUPPORTED_WEBHOOK_PROTOCOLS = ['http:', HTTPS_PROTOCOL];
|
||||
const DEFAULT_HTTP_WEBHOOK_PORT = 80;
|
||||
const DEFAULT_HTTPS_WEBHOOK_PORT = 443;
|
||||
const DEFAULT_LEGACY_WEBHOOK_HOST = '127.0.0.1';
|
||||
const DEFAULT_LEGACY_WEBHOOK_PORT = 9003;
|
||||
|
||||
// https://www.ditig.com/publications/256-colors-cheat-sheet
|
||||
const MIN_COLOR = 17;
|
||||
const MAX_COLOR = 231;
|
||||
@@ -105,8 +111,9 @@ export async function init(args: ParsedArgs): Promise<void> {
|
||||
}
|
||||
}
|
||||
});
|
||||
// Only Winston produces output; avoid duplicate lines from the logger's default stream
|
||||
// Only Winston produces output; avoid duplicate lines from the logger's default streams
|
||||
globalLog.stream = null;
|
||||
globalLog.errorStream = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,20 +150,47 @@ function createFileTransport(args: ParsedArgs, logLvl: string): transports.FileT
|
||||
return new transports.File(opt);
|
||||
}
|
||||
|
||||
function createHttpTransport(args: ParsedArgs, logLvl: string): transports.HttpTransportInstance {
|
||||
let host = '127.0.0.1';
|
||||
let port = 9003;
|
||||
|
||||
if (args.webhook?.match(':')) {
|
||||
const hostAndPort = args.webhook.split(':');
|
||||
host = hostAndPort[0];
|
||||
port = parseInt(hostAndPort[1], 10);
|
||||
/**
|
||||
* Parse a `--webhook` / `server.webhook` value into Winston Http transport options.
|
||||
*
|
||||
* The schema documents it as a URI (e.g. `http://0.0.0.0/hook`). A bare `host:port`
|
||||
* is still accepted and always posts to the root path over plain http.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function parseWebhookUri(
|
||||
webhook: string,
|
||||
): Pick<transports.HttpTransportOptions, 'host' | 'port' | 'path' | 'ssl'> {
|
||||
// `host:port` parses too, into a URI whose scheme is the host name and no host at all
|
||||
const url = URL.parse(webhook);
|
||||
if (url?.hostname) {
|
||||
if (!SUPPORTED_WEBHOOK_PROTOCOLS.includes(url.protocol)) {
|
||||
throw new Error(
|
||||
`The logging webhook must be an http(s) URL, for example 'http://localhost:9003/logs'. ` +
|
||||
`'${webhook}' is given instead`,
|
||||
);
|
||||
}
|
||||
const isSsl = url.protocol === HTTPS_PROTOCOL;
|
||||
const defaultPort = isSsl ? DEFAULT_HTTPS_WEBHOOK_PORT : DEFAULT_HTTP_WEBHOOK_PORT;
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: url.port ? parseInt(url.port, 10) : defaultPort,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
ssl: isSsl,
|
||||
};
|
||||
}
|
||||
|
||||
const opt: transports.HttpTransportOptions = {
|
||||
host,
|
||||
port,
|
||||
const [host, port] = webhook.includes(':') ? webhook.split(':') : ['', ''];
|
||||
return {
|
||||
host: host || DEFAULT_LEGACY_WEBHOOK_HOST,
|
||||
port: parseInt(port, 10) || DEFAULT_LEGACY_WEBHOOK_PORT,
|
||||
path: '/',
|
||||
ssl: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createHttpTransport(args: ParsedArgs, logLvl: string): transports.HttpTransportInstance {
|
||||
const opt: transports.HttpTransportOptions = {
|
||||
...parseWebhookUri(args.webhook as string),
|
||||
level: logLvl,
|
||||
format: format.combine(stripColorFormat, formatLog(args, false)),
|
||||
};
|
||||
@@ -170,7 +204,7 @@ async function createTransports(args: ParsedArgs): Promise<Transport[]> {
|
||||
|
||||
// Server args are normalized in main so we only see dest form (`loglevel`).
|
||||
// Fall back to schema default so Winston never sees undefined.
|
||||
const rawLogLevel = args.loglevel ?? 'debug';
|
||||
const rawLogLevel = args.loglevel ?? 'info';
|
||||
|
||||
if (rawLogLevel && rawLogLevel.includes(':')) {
|
||||
// --log-level arg can optionally provide diff logging levels for console and file, separated by a colon
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user