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