mirror of
https://github.com/appium/appium.git
synced 2026-11-09 19:38:26 +08:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2e53e15e5 | ||
|
|
b09efc31c6 | ||
|
|
e303649a42 | ||
|
|
d2483b8bf4 | ||
|
|
b78454ab3e | ||
|
|
83b7835733 | ||
|
|
33d866e575 | ||
|
|
0b104f6bf9 | ||
|
|
93115d1957 | ||
|
|
ac01aa83ad | ||
|
|
0e91fb756b | ||
|
|
8b62a7971a | ||
|
|
a73644fb4f | ||
|
|
33def2cc0b | ||
|
|
b7b09f750f | ||
|
|
b129cbbcd5 | ||
|
|
d6ef7e3f12 | ||
|
|
b25fc20c51 | ||
|
|
74d1ee6d17 | ||
|
|
bcf1c57999 | ||
|
|
5e644a392f | ||
|
|
ff458d7cac | ||
|
|
812ca2a9af | ||
|
|
6323df1d73 | ||
|
|
4eb4d7f317 | ||
|
|
506bfeeb9a | ||
|
|
0df8834638 | ||
|
|
add1615607 | ||
|
|
8840f9f932 | ||
|
|
926a0a5949 | ||
|
|
db72249873 | ||
|
|
f71d980779 | ||
|
|
d82d496f9e | ||
|
|
fe24f527a4 | ||
|
|
5875b94fc4 | ||
|
|
62db5f0b20 | ||
|
|
ef8a8e1934 | ||
|
|
8709844b21 | ||
|
|
464c271fbc | ||
|
|
8db8e5748e | ||
|
|
8e8c00e6db | ||
|
|
c6e42f3fb9 | ||
|
|
fbeb496142 | ||
|
|
9f4a11e719 | ||
|
|
8b7bf30d43 | ||
|
|
835119d1ce | ||
|
|
0b83e77e7a | ||
|
|
454c22f4c2 | ||
|
|
a38539deaf | ||
|
|
e8ee71685b | ||
|
|
8fcdcb30b0 | ||
|
|
ec7de4768f | ||
|
|
cd0d8f14e9 | ||
|
|
3516e50ce6 |
@@ -23,8 +23,6 @@ targets:
|
||||
path: ./packages/appium
|
||||
- type: npm
|
||||
path: ./packages/base-driver
|
||||
- type: npm
|
||||
path: ./packages/eslint-config-appium-ts
|
||||
- type: npm
|
||||
path: ./packages/fake-driver
|
||||
- type: npm
|
||||
|
||||
@@ -68,11 +68,6 @@ labels:
|
||||
matcher:
|
||||
files: ['packages/driver-test-support/**']
|
||||
|
||||
- label: '@appium/eslint-config-appium-ts'
|
||||
sync: true
|
||||
matcher:
|
||||
files: ['packages/eslint-config-appium-ts/**']
|
||||
|
||||
- label: '@appium/oxc-config'
|
||||
sync: true
|
||||
matcher:
|
||||
|
||||
@@ -3,6 +3,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- appium4
|
||||
# this list of paths could potentially impact the build
|
||||
paths:
|
||||
- '**/tsconfig*.json'
|
||||
@@ -23,6 +24,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- appium4
|
||||
paths:
|
||||
- '**/tsconfig*.json'
|
||||
- 'packages/base-driver/test-fixtures/**/*'
|
||||
|
||||
@@ -10,13 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
prepare_matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
versions: ${{ steps.generate-matrix.outputs.lts }}
|
||||
steps:
|
||||
- name: Select all current LTS versions of Node.js
|
||||
id: generate-matrix
|
||||
uses: msimerson/node-lts-versions@v2.0.2
|
||||
uses: appium/appium-workflows/.github/workflows/node-lts-matrix.yml@main
|
||||
|
||||
check:
|
||||
needs:
|
||||
|
||||
@@ -13,11 +13,11 @@ 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
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -58,43 +58,29 @@ 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
|
||||
|
||||
# On the beta branch ($BETA_BRANCH), every release is a beta prerelease anchored at its
|
||||
# target major (see packages/semantic-release-config's "Next-major beta branches" docs
|
||||
# for the equivalent semantic-release behavior this mirrors).
|
||||
#
|
||||
# allow_legacy_peer_deps: independent next-major beta.0 bumps can briefly outrun a
|
||||
# dependent's committed peerDependencies range, aborting lerna's internal lockfile
|
||||
# refresh with ERESOLVE. Opt-in only - it masks a real (if temporary) peer mismatch.
|
||||
LEGACY_PEER_DEPS_FLAGS=""
|
||||
if [ "$ALLOW_LEGACY_PEER_DEPS" = "true" ]; then
|
||||
LEGACY_PEER_DEPS_FLAGS="--npm-client-args=--legacy-peer-deps"
|
||||
fi
|
||||
|
||||
BETA_FLAGS=""
|
||||
DIST_TAG_FLAGS=""
|
||||
if [ "${{ github.ref_name }}" = "$BETA_BRANCH" ]; then
|
||||
BETA_FLAGS="--conventional-prerelease --preid beta"
|
||||
BETA_FLAGS="--conventional-prerelease --preid beta $LEGACY_PEER_DEPS_FLAGS"
|
||||
DIST_TAG_FLAGS="--dist-tag beta"
|
||||
fi
|
||||
|
||||
# One-time bootstrap for packages just starting their beta train: force them
|
||||
# straight to <next-major>.0.0-beta.0 instead of waiting for/relying on a commit that
|
||||
# happens to be marked as breaking. Not part of the normal release flow below - this
|
||||
# exits early on its own.
|
||||
if [ -n "$BOOTSTRAP_PACKAGES" ]; then
|
||||
if [ "${{ github.ref_name }}" != "$BETA_BRANCH" ]; then
|
||||
echo "bootstrap_packages is only meaningful on the $BETA_BRANCH branch" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RECOVER_MISSING_ONLY" = "true" ]; then
|
||||
echo "bootstrap_packages cannot be combined with recover_missing_only" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Bootstrapping ${BOOTSTRAP_PACKAGES} to their next-major beta (premajor --preid beta), bypassing conventional-commit detection"
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
printf "n\n" | npx lerna version premajor --preid beta --exact --no-git-tag-version --no-push --force-publish="$BOOTSTRAP_PACKAGES"
|
||||
else
|
||||
npx lerna publish premajor --preid beta --exact --force-publish="$BOOTSTRAP_PACKAGES" --dist-tag beta --yes
|
||||
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() {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import appiumConfig from '@appium/eslint-config-appium-ts';
|
||||
import {defineConfig, globalIgnores} from 'eslint/config';
|
||||
|
||||
// Create a modified config subset for test support files:
|
||||
// Extract the test file related configs (Mocha plugin and custom rules),
|
||||
// then override their 'files' property (match by name — resilient to new config entries).
|
||||
const testFileConfigItems = appiumConfig.filter(
|
||||
(item) => typeof item?.name === 'string' && item.name.startsWith('Test Files'),
|
||||
);
|
||||
const testSupportFiles = ['packages/driver-test-support/lib/**', 'packages/plugin-test-support/lib/**'];
|
||||
const testSupportConfig = testFileConfigItems.map((item) => ({...item, files: testSupportFiles}));
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
name: 'Base Config',
|
||||
extends: [appiumConfig],
|
||||
},
|
||||
{
|
||||
name: 'Test Support',
|
||||
extends: [testSupportConfig],
|
||||
},
|
||||
{
|
||||
name: 'Consistent type-only imports (all packages)',
|
||||
files: ['**/*.{ts,tsx,mtsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{fixStyle: 'separate-type-imports', prefer: 'type-imports'},
|
||||
],
|
||||
},
|
||||
},
|
||||
globalIgnores([
|
||||
'**/build-fixtures/**',
|
||||
'packages/appium/docs/**/assets/**',
|
||||
'packages/appium/docs/**/js/**',
|
||||
'packages/appium/sample-code/**',
|
||||
]),
|
||||
]);
|
||||
Generated
+4348
-7612
File diff suppressed because it is too large
Load Diff
+8
-9
@@ -81,9 +81,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@appium/oxc-config": "1.0.0",
|
||||
"@tsconfig/node20": "20.1.10",
|
||||
"@tsconfig/node22": "22.0.0",
|
||||
"@types/async-lock": "1.4.2",
|
||||
"@types/bluebird": "3.5.42",
|
||||
"@types/content-disposition": "0.5.9",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/finalhandler": "1.2.4",
|
||||
@@ -92,9 +91,10 @@
|
||||
"@types/klaw": "3.0.7",
|
||||
"@types/lockfile": "1.0.4",
|
||||
"@types/method-override": "3.0.0",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/morgan": "1.9.10",
|
||||
"@types/node": "24.13.6",
|
||||
"@types/plist": "3.0.5",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/picomatch": "4.0.3",
|
||||
"@types/pluralize": "0.0.33",
|
||||
"@types/semver": "7.8.0",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
@@ -103,24 +103,23 @@
|
||||
"@types/which": "3.0.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.35",
|
||||
"asyncbox": "6.4.3",
|
||||
"asyncbox": "6.4.2",
|
||||
"conventional-changelog-conventionalcommits": "9.3.1",
|
||||
"cpy-cli": "7.0.0",
|
||||
"cross-env": "10.1.0",
|
||||
"finalhandler": "2.1.1",
|
||||
"json-schema-to-typescript": "16.0.0",
|
||||
"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",
|
||||
"rewiremock": "3.14.6",
|
||||
"rimraf": "6.1.3",
|
||||
"serve-static": "2.2.1",
|
||||
"sinon": "22.1.0",
|
||||
"teen_process": "4.2.1",
|
||||
"tsd": "0.33.0",
|
||||
"typescript": "6.0.3",
|
||||
"webdriverio": "9.31.9",
|
||||
"webdriverio": "9.31.2",
|
||||
"ws": "8.21.3"
|
||||
},
|
||||
"overrides": {
|
||||
@@ -133,7 +132,7 @@
|
||||
"http-cache-semantics": "4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0",
|
||||
"npm": ">=10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,109 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [3.8.0](https://github.com/appium/appium/compare/appium@3.7.0...appium@3.8.0) (2026-09-24)
|
||||
|
||||
### Features
|
||||
|
||||
* **appium:** accept package names for installed extension commands ([#22747](https://github.com/appium/appium/issues/22747)) ([6c911de](https://github.com/appium/appium/commit/6c911debd72cb7fe094d27671d1cf14098ff537a))
|
||||
## [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
|
||||
|
||||
* **appium:** do not treat a directory as a CLI argument file ([#22709](https://github.com/appium/appium/issues/22709)) ([6ba6faf](https://github.com/appium/appium/commit/6ba6faf6f533192afbdf9462dbbb3a5c1e5d85c5))
|
||||
* **appium:** filter prerelease versions in driver update e2e test ([#22794](https://github.com/appium/appium/issues/22794)) ([4e8e334](https://github.com/appium/appium/commit/4e8e334cbc0ae910f521ae7ff7d3ed15f9be0a6a))
|
||||
* **appium:** match insecure feature prefixes by automationName, case-insensitively ([#22710](https://github.com/appium/appium/issues/22710)) ([6ab17fe](https://github.com/appium/appium/commit/6ab17feb71d74c1f349616f22896faeb256a66ec))
|
||||
* **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
|
||||
|
||||
* **base-driver:** align timeouts endpoints closer to W3C standard (#22776)
|
||||
* users relying on the implicit debug default for console/file logs will now see info-level output unless they explicitly pass --log-level debug.
|
||||
* **support:** Any script or tool that consumes Appium CLI output (driver/plugin list/install/update, doctor, etc.) and relies on the previous stream layout will need to be updated. Previously, non-JSON human-readable output (info/success/warning messages) was written to STDERR, with STDOUT reserved almost exclusively for --json output. Now that output goes to STDOUT, and only actual errors go to STDERR.
|
||||
* **support:** Scripts that discarded STDOUT (1>/dev/null) to suppress "chatter" while relying on STDERR for status text will now see that text on STDOUT instead.
|
||||
* **support:** Scripts that captured STDERR to detect/log informational or success messages will no longer see them there — only genuine errors will appear on STDERR. --json output and error behavior are unaffected: --json continues to write clean JSON to STDOUT only, and errors continue to go to STDERR.
|
||||
* **support:** @appium/logger's Log class changes its default output stream from STDERR to STDOUT for all levels except error, and adds an errorStream/stderrLevel pair of properties. Any code that set .stream on a Log instance expecting it to capture all levels (including error) will now need to also set .errorStream to the same target.
|
||||
* **base-driver:** @appium/base-driver's default export (BaseDriver) and @appium/base-driver's basedriver/helpers.js default export are removed; use the named exports instead. The appium/driver.js and driver.d.ts re-exports of the default are removed accordingly.
|
||||
* **base-driver:** BaseDriver.reset() is removed; it was unused, not part of any protocol route or the Driver interface, and had no callers in this monorepo or any driver package that was checked.
|
||||
* **base-driver:** BaseDriver's constructor is removed in favor of inherited DriverCore construction; opts is no longer a required constructor argument (it now defaults to {}, matching DriverCore).
|
||||
* **support:** @appium/support's fs.glob option shape changed from the glob package's GlobOptions to a smaller, @appium/support-owned GlobOptions interface. Supported fields: cwd, withFileTypes, absolute, lazy. Options like nodir/ignore/nocase are no longer supported directly — callers needing directory filtering can pass withFileTypes: true and filter on .isFile()/.isDirectory(); callers needing path exclusion can filter the returned array by pattern.
|
||||
* **appium:** The --allow-cors CLI flag and allow-cors config file property have been removed. Use --allow-insecure=*:cors (or --relaxed-security, optionally overridden with --deny-insecure=*:cors) instead.
|
||||
* **base-driver:** ISessionHandler.createSession (@appium/types) and LegacyCreateSessionArgs — overload and type deleted, single-argument signature only.
|
||||
* **base-driver:** BaseDriver.createSession and AppiumDriver.createSession — collapsed to the single-argument mplementation; capability validation now checks the one argument directly instead of scanning three positions for the first W3C-shaped value.
|
||||
* **base-driver:** FakeDriver.createSession (and the base-driver e2e test double) — overrides updated to match.
|
||||
* **base-driver:** RelaxedCapsPlugin.createSession — this plugin existed specifically to normalize capabilities across the (now-gone) three positions; its hook is simplified to a single caps argument.
|
||||
* **base-driver:** POST /session wire route (w3c.ts) — payloadParams.optional no longer sends capabilities three times into the command args.
|
||||
* **base-driver:** BaseDriver.getSession, ISessionHandler.getSession, and the GET /session/:sessionId JSONWP route are gone. Use getAppiumSessionCapabilities to retrieve session capabilities and EventCommands.getLogEvents to retrieve event history instead.
|
||||
* **base-driver:** The appium:eventTimings capability is removed; it only ever controlled whether getSession's response was decorated with event history, which getLogEvents already exposes unconditionally.
|
||||
* **support:** move npm-related helpers into appium package (#22721)
|
||||
* **logger:** @appium/logger and @appium/support no longer export a default log — import the named log export instead.
|
||||
* **logger:** AppiumLogger#errorAndThrow has been removed; use errorWithException instead.
|
||||
* **logger:** Logger#enableProgress, disableProgress, progressEnabled, enableUnicode, disableUnicode have been removed; they were already permanently disabled no-ops.
|
||||
* **base-plugin:** Plugin#logger has been removed; use Plugin#log instead.
|
||||
* **base-plugin:** @appium/base-plugin no longer exports BasePlugin as the default export; use the named BasePlugin export.
|
||||
* Core.driverData, DriverData, and the driverData parameter on ISessionHandler.createSession/deleteSession are gone. Any driver overriding get driverData() or relying on the umbrella driver passing sibling-session data into createSession/deleteSession needs to migrate off this mechanism.
|
||||
* FakeDriver no longer supports the uniqueApp capability.
|
||||
* **support:** npm.installPackage now takes a hasAppiumDependency boolean computed by the caller instead of importing the check itself, avoiding a support -> appium dependency.
|
||||
* **support:** The env export is removed from @appium/support.
|
||||
* the minimum supported Node.js engine is set to ^22.22.2 || ^24.15.0 || >=26.0.0
|
||||
* **base-driver:** @appium/base-driver no longer exports statusCodes or getSummaryByCode (the jsonwp-status module was removed).
|
||||
* **base-driver:** errorFromMJSONWPStatusCode (aliased as errorFromCode) has been removed; use errorFromW3CJsonCode for W3C error-signature mapping.
|
||||
* **base-driver:** ProtocolError instances no longer have a jsonwpCode property.
|
||||
* **base-driver:** WebDriverProxy (formerly JWProxy) no longer proxies to or interprets responses from downstream servers speaking the legacy MJSONWP protocol; the ProtocolConverter class, COMMAND_URLS_CONFLICTS, and the downstreamProtocol getter/setter have been removed. Downstream automation servers must speak W3C WebDriver.
|
||||
* **base-driver:** Responses no longer duplicate element references under the legacy ELEMENT key; only the W3C element-6066-11e4-a52e-4f735466cecf key is present. The MJSONWP_ELEMENT_KEY constant has been removed.
|
||||
* **base-driver:** determineProtocol has been removed, and drivers can no longer produce the legacy {sessionId, status, value} response shape; all responses are now W3C-shaped regardless of how the client formatted its createSession capabilities.
|
||||
* **base-driver:** DriverCore#setProtocolMJSONWP() has been removed.
|
||||
* **base-driver:** The deprecated JWProxy export has been removed; use WebDriverProxy instead. Its source also moved from lib/jsonwp-proxy/proxy.js to lib/wd-proxy/proxy.js.
|
||||
* **base-driver:** DriverCore#isMjsonwpProtocol() has been removed.
|
||||
* **support:** The following @appium/support exports have been removed: process (getProcessIds, killProcess), mkdirp, imageUtil (requireSharp, cropBase64Image), mjpeg (MJpegStream), net's FTP upload
|
||||
support (FtpUploadOptions, NotHttpUploadOptions; uploadFile only supports http/https now), util's uuidV1/uuidV3/uuidV5/localIp/cancellableDelay/multiResolve, and fs's readPackageJsonFrom/findRoot/ F_OK/R_OK/W_OK/X_OK.
|
||||
* **appium:** The --nodeconfig server argument and the server.nodeconfig config file property no longer exist. Users who need to register with a Selenium Grid should use the Grid 4 relay feature instead (see the Grid guide).
|
||||
* **base-driver:** TLS-enabled Appium servers no longer negotiate SPDY/HTTP2 (spdy/2, spdy/3, spdy/3.1, h2) via ALPN; connections are now served as plain HTTPS (HTTP/1.1) using Node's native https module. Clients relying on SPDY or HTTP/2 multiplexing against Appium's TLS listener must switch to HTTP/1.1.
|
||||
* **appium:** `npm install -g appium --drivers=... --plugins=...` no longer installs extensions automatically. Use `appium driver/plugin install` or add extensions to package.json instead.
|
||||
* require('@appium/support') no longer works; consumers must import it instead.
|
||||
* require('@appium/base-driver') no longer works; drivers extending BaseDriver via require must switch to import.
|
||||
* require('@appium/base-plugin') no longer works; plugins extending BasePlugin via require must switch to import.
|
||||
* require('appium') no longer works; plugins extending the package via require must switch to import.
|
||||
* require('@appium/fake-plugin') no longer works; consumers must import it instead.
|
||||
* require('@appium/fake-driver') no longer works; consumers must import it instead.
|
||||
* require('@appium/types') no longer works; consumers must import it instead.
|
||||
* require('@appium/logger') no longer works; consumers must import it instead.
|
||||
* require('@appium/schema') no longer works; consumers must import it instead.
|
||||
|
||||
### Features
|
||||
|
||||
* **appium:** add --app-url-rules server arg to restrict remote app URLs ([#22753](https://github.com/appium/appium/issues/22753)) ([0b104f6](https://github.com/appium/appium/commit/0b104f6bf9b7180b111df40fd6a15b26d649eabd))
|
||||
* **appium:** remove postinstall autoinstall-extensions script ([#22675](https://github.com/appium/appium/issues/22675)) ([cd0d8f1](https://github.com/appium/appium/commit/cd0d8f14e9d5906ecd1b438038a7f67f74f742fc))
|
||||
* **appium:** remove Selenium Grid 3 support ([#22677](https://github.com/appium/appium/issues/22677)) ([8fcdcb3](https://github.com/appium/appium/commit/8fcdcb30b07c1ee582256fbef3538d6a3ceea5f2))
|
||||
* **appium:** replace --allow-cors flag with --allow-insecure=*:cors ([#22731](https://github.com/appium/appium/issues/22731)) ([926a0a5](https://github.com/appium/appium/commit/926a0a594935a6cf2e7375eb174eeb15af964f12))
|
||||
* **appium:** validate extension manifests and extensions.yaml via ajv ([#22732](https://github.com/appium/appium/issues/22732)) ([8840f9f](https://github.com/appium/appium/commit/8840f9f9322b63bc8940c390b8230f1e5306fa62))
|
||||
* backport diff from master ([#22749](https://github.com/appium/appium/issues/22749)) ([6323df1](https://github.com/appium/appium/commit/6323df1d7354f85cc631508f5cb6a3e35270d4bd))
|
||||
* **base-driver:** add definitions for all WebDriver BiDi commands ([#22770](https://github.com/appium/appium/issues/22770)) ([ac01aa8](https://github.com/appium/appium/commit/ac01aa83ad73982eea48f856dfc0249b4a26e395))
|
||||
* **base-driver:** align timeouts endpoints closer to W3C standard ([#22776](https://github.com/appium/appium/issues/22776)) ([93115d1](https://github.com/appium/appium/commit/93115d195773b011306ffb2e6d7d6cc935df0148))
|
||||
* **base-driver:** remove legacy (M)JSONWP protocol support ([#22681](https://github.com/appium/appium/issues/22681)) ([0b83e77](https://github.com/appium/appium/commit/0b83e77e7a1c24a885d9895a99db30281cf4acea))
|
||||
* **base-driver:** replace spdy with native https transport ([#22676](https://github.com/appium/appium/issues/22676)) ([ec7de47](https://github.com/appium/appium/commit/ec7de4768fdd8dde9d28db6fc2dfd63be45fd4c6))
|
||||
* bump minimum supported Node.js engine to ^22.22.2 || ^24.15.0 || >=26.0.0 ([#22685](https://github.com/appium/appium/issues/22685)) ([9f4a11e](https://github.com/appium/appium/commit/9f4a11e7190290986d01743557e90e6e44f87638))
|
||||
* Change the default log level to `info` ([#22756](https://github.com/appium/appium/issues/22756)) ([5e644a3](https://github.com/appium/appium/commit/5e644a392fbf11e2ca985408853d7f51aa85be17))
|
||||
* convert remaining monorepo packages to ESM-only ([#22674](https://github.com/appium/appium/issues/22674)) ([3516e50](https://github.com/appium/appium/commit/3516e50ce6d022f4c0dc539071c593bf2983325f))
|
||||
* **support:** move env/APPIUM_HOME resolution into appium package ([#22708](https://github.com/appium/appium/issues/22708)) ([8db8e57](https://github.com/appium/appium/commit/8db8e5748e26a7150cacbea09ba3ec52c5ce3fc0))
|
||||
* **support:** remove deprecated APIs ([#22678](https://github.com/appium/appium/issues/22678)) ([e8ee716](https://github.com/appium/appium/commit/e8ee71685b4c21116e61c88fd8d2f0e39fac2edb))
|
||||
* **types:** check execute method params against the command signature ([#22754](https://github.com/appium/appium/issues/22754)) ([b25fc20](https://github.com/appium/appium/commit/b25fc20c51a191c33695c1c71fbf129de834b52d))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address code review cleanups (plist, env memoize, test reuse) ([#22740](https://github.com/appium/appium/issues/22740)) ([0df8834](https://github.com/appium/appium/commit/0df883463812d57d44a0d6d80e9b88f124361934))
|
||||
* Address several TODOs ([#22762](https://github.com/appium/appium/issues/22762)) ([d6ef7e3](https://github.com/appium/appium/commit/d6ef7e3f1240f161df622c850aea06637f9d1078))
|
||||
* adjust deprecated JSONWP/MJSONWP routes ([#22763](https://github.com/appium/appium/issues/22763)) ([b129cbb](https://github.com/appium/appium/commit/b129cbbcd55656ae7f33b80b4b52719eb5179509))
|
||||
* **appium:** lock the extension manifest while driver/plugin commands run ([#22767](https://github.com/appium/appium/issues/22767)) ([a73644f](https://github.com/appium/appium/commit/a73644fb4f4e7b0363117ac1262d7018094b42ff))
|
||||
* **base-driver:** clean up aborted session creation ([#22733](https://github.com/appium/appium/issues/22733)) ([33d866e](https://github.com/appium/appium/commit/33d866e57506e1546aa4870dde75f7209be107bc))
|
||||
* declare dependencies that were only resolving via hoisting ([#22758](https://github.com/appium/appium/issues/22758)) ([bcf1c57](https://github.com/appium/appium/commit/bcf1c579997b8dbb8292b1a77583aeef40b11ba4))
|
||||
* **support:** route non-error CLI console output to stdout ([#22742](https://github.com/appium/appium/issues/22742)) ([4eb4d7f](https://github.com/appium/appium/commit/4eb4d7f317a685d681b6cf4e25c15b617a8cc7df))
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **base-driver:** remove deprecated getSession command ([#22725](https://github.com/appium/appium/issues/22725)) ([f71d980](https://github.com/appium/appium/commit/f71d980779d23e9a19d33285646fad128eae4917))
|
||||
* **base-driver:** remove deprecated multi-argument createSession overload ([#22730](https://github.com/appium/appium/issues/22730)) ([db72249](https://github.com/appium/appium/commit/db722498739ae7fe1deef1b98e02d04b455a4f70))
|
||||
* **base-driver:** replace command mixin with explicit named exports ([#22741](https://github.com/appium/appium/issues/22741)) ([506bfee](https://github.com/appium/appium/commit/506bfeeb9aa15c0dd1f481868d347cb9b0d37f86))
|
||||
* **base-plugin:** remove deprecated legacy logger and default export ([#22718](https://github.com/appium/appium/issues/22718)) ([62db5f0](https://github.com/appium/appium/commit/62db5f0b20c496b495ce27d490453861ddefb259))
|
||||
* **logger:** remove default exports and dead logging APIs ([#22719](https://github.com/appium/appium/issues/22719)) ([fe24f52](https://github.com/appium/appium/commit/fe24f527a4412ed86c7b5e5afe0c6e26dc569e6b))
|
||||
* remove deprecated driverData mechanism ([#22716](https://github.com/appium/appium/issues/22716)) ([ef8a8e1](https://github.com/appium/appium/commit/ef8a8e1934bfdc9699c2b9a7eca3e2201b4b9f93))
|
||||
* **support:** move npm-related helpers into appium package ([#22721](https://github.com/appium/appium/issues/22721)) ([d82d496](https://github.com/appium/appium/commit/d82d496f9e80d6628abface3b623601a6f9791f9))
|
||||
* **support:** replace glob package with native node:fs glob ([#22735](https://github.com/appium/appium/issues/22735)) ([add1615](https://github.com/appium/appium/commit/add161560701ac7fb2bc372ddefd9317146fbae8))
|
||||
|
||||
|
||||
## [3.7.0](https://github.com/appium/appium/compare/appium@3.6.0...appium@3.7.0) (2026-08-24)
|
||||
|
||||
@@ -229,16 +229,6 @@ async createSession(w3cCaps) {
|
||||
}
|
||||
```
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Older drivers may still define `createSession` with up to four parameters, e.g.
|
||||
`createSession(jwpCaps, reqCaps, w3cCaps, otherDriverData)`. This shape is a holdover from the
|
||||
retired JSON Wire Protocol, which required desired and required caps as the first two
|
||||
arguments; only the first W3C-shaped argument was ever used. The `otherDriverData` parameter is
|
||||
likewise deprecated in favor of [IPC](#send-messages-to-plugins-running-on-the-same-session) for
|
||||
coordinating with other concurrently-running sessions. New drivers should use the single-argument
|
||||
form shown above.
|
||||
|
||||
You'll want to make sure to call `super.createSession` in order to get the session ID as well as
|
||||
the processed capabilities (note that capabilities are also set on `this.caps`; modifying `caps`
|
||||
locally here would have no effect other than changing what the user sees in the create session
|
||||
@@ -544,7 +534,7 @@ this.proxyCommand = proxy.command.bind(proxy);
|
||||
Here we are creating a proxy object and assigning some of its methods to `this` under the names
|
||||
`proxyReqRes` and `proxyCommand`. This is required for Appium to use the proxy, so don't forget
|
||||
this step! The `WebDriverProxy` has a variety of other options [which you can check out in the
|
||||
source code as well.](https://github.com/appium/appium/blob/master/packages/base-driver/lib/jsonwp-proxy/proxy.ts)
|
||||
source code as well.](https://github.com/appium/appium/blob/master/packages/base-driver/lib/wd-proxy/proxy.ts)
|
||||
|
||||
Finally, we need a way to tell Appium when the proxy is active. For your driver it might always
|
||||
be active, or it might only be active when in a certain context. You can define the logic as an
|
||||
@@ -724,6 +714,12 @@ A couple notes about this system:
|
||||
mode!
|
||||
1. The `executeMethod` helper will reject with an error if a script name doesn't match one of the
|
||||
script names defined as a command in `executeMethodMap`, or if there are missing parameters.
|
||||
1. If your driver is written in TypeScript, declare the map `as const satisfies
|
||||
ExecuteMethodMap<MyDriver>` (with `ExecuteMethodMap` imported from `@appium/types`). The
|
||||
compiler then checks that each `command` names a method of your driver, and that the
|
||||
`required`/`optional` params are consistent with that method's signature: the method must not
|
||||
require a parameter which is listed as `optional` (or not listed at all), and must accept all
|
||||
of the listed parameters.
|
||||
|
||||
One of the nice things about the Execute Method strategy is that methods implemented in this way
|
||||
will be available via the classic or BiDi interfaces (since they will result in the same Appium
|
||||
@@ -798,50 +794,32 @@ that multiple simultaneous sessions don't use the same resources:
|
||||
|
||||
1. Have your users specify resource IDs via capabilities (`appium:driverPort` etc)
|
||||
1. Just always use free resources (find a new random port for each session)
|
||||
1. Have each driver express what resources it is using, then examine currently-used resources from
|
||||
other drivers when a new session begins.
|
||||
1. Have each running instance of your driver publish and subscribe to messages on a shared IPC
|
||||
channel, so sessions can coordinate directly with one another.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
The `driverData`-based mechanism described below is deprecated. Prefer
|
||||
[IPC](#send-messages-to-plugins-running-on-the-same-session) for coordinating resources across
|
||||
concurrently-running sessions instead.
|
||||
|
||||
To support this third strategy, you can implement `get driverData` in your driver to return what
|
||||
sorts of resources your driver is currently using, for example:
|
||||
|
||||
```js
|
||||
get driverData() {
|
||||
return {specialPort: 1234, specialFile: /path/to/file}
|
||||
}
|
||||
```
|
||||
|
||||
Now, when a new session is started on your driver, the `driverData` response from any other
|
||||
simultaneously running drivers (of the same type) will also be included, as the last parameter of
|
||||
the deprecated, multi-argument form of the `createSession` method:
|
||||
|
||||
```js
|
||||
async createSession(jwpCaps, reqCaps, w3cCaps, driverData)
|
||||
```
|
||||
|
||||
You can dig into this `driverData` array to see what resources other drivers are using to help
|
||||
determine which ones you want to use for this particular session.
|
||||
The per-session IPC channel described in [Send messages to plugins running on the same
|
||||
session](#send-messages-to-plugins-running-on-the-same-session) is isolated per session, so it
|
||||
can't be used for this: it doesn't let you talk to *other* sessions of your driver. Instead,
|
||||
construct and hold on to your own `AppiumIpc` instance (exported from `appium/driver.js`) at
|
||||
module scope in your driver's package, for example as a memoized singleton. Since Node.js caches
|
||||
modules, every instance of your driver loaded within the same Appium server process will share
|
||||
that one `AppiumIpc` object, and can use it to publish and subscribe to topics across sessions —
|
||||
the same way the per-session channel works, just not scoped to a single session. See
|
||||
[xcuitest-driver's `session-claim-handler.ts`](https://github.com/appium/appium-xcuitest-driver/blob/master/lib/session-claim-handler.ts)
|
||||
for a real example, which uses this technique to detect and resolve conflicting sessions targeting
|
||||
the same device UDID.
|
||||
|
||||
!!! warning
|
||||
|
||||
Be careful here, since `driverData` is only passed between sessions of a single running Appium
|
||||
server. There's nothing to stop a user from running multiple Appium servers and requesting your
|
||||
driver simultaneously on each of them. In this case, you won't be able to ensure independence
|
||||
of resources via `driverData`, so you might consider using file-based locking mechanisms or
|
||||
something similar.
|
||||
This only works for sessions running within the same Node.js process (i.e. the same Appium
|
||||
server). It cannot coordinate sessions running on different Appium server processes; for that,
|
||||
consider file-based locking or a similar out-of-process mechanism.
|
||||
|
||||
!!! warning
|
||||
|
||||
It's also important to note you will only receive `driverData` for other instances of *your*
|
||||
driver. So unrelated drivers also running may still be using some system resources. In general
|
||||
Appium doesn't provide any features for ensuring unrelated drivers don't interfere with one
|
||||
another, so it's up to the drivers to allow users to specify resource locations or addresses to
|
||||
avoid clashes.
|
||||
Appium also doesn't provide any features for ensuring unrelated drivers don't interfere with
|
||||
one another, so it's up to the drivers to allow users to specify resource locations or
|
||||
addresses to avoid clashes.
|
||||
|
||||
### Log events to the Appium event timeline
|
||||
|
||||
@@ -989,10 +967,15 @@ export type IpcMessage<T> = {
|
||||
#### IPC Notes
|
||||
|
||||
There are some important things to keep in mind when using Appium's IPC feature:
|
||||
- IPC is only available once `onIpcInit` has been called, at the end of the `createSession` flow.
|
||||
It cannot be used statically or in `createSession` hooks
|
||||
- In sum, IPC is only for use during a session (this is also to prevent drivers/plugins from
|
||||
accessing or reading data sent on IPC channels in other sessions.)
|
||||
- The `ipcSubscribe` method (and the IPC channel it subscribes to) is only available once
|
||||
`onIpcInit` has been called, at the end of the `createSession` flow. It cannot be used statically
|
||||
or in `createSession` hooks
|
||||
- The channel that `ipcSubscribe` gives you access to is scoped to the current session, and is
|
||||
shared only with the plugins active in that same session (this is also to prevent drivers/plugins
|
||||
from accessing or reading data sent on IPC channels in other sessions.) To coordinate across
|
||||
multiple concurrent sessions of your own driver instead, see [Make itself aware of resources
|
||||
other concurrent drivers are
|
||||
using](#make-itself-aware-of-resources-other-concurrent-drivers-are-using).
|
||||
- The default max size of an IPC message is 1MB. This can be configured by the server-admin by
|
||||
using the `--max-ipc-data-size` arg (value is a number in bytes).
|
||||
- If a message exceeds the configured size, any call to `publish` methods will throw, so be
|
||||
|
||||
@@ -257,7 +257,7 @@ throw new errors.NoSuchElementError();
|
||||
### Log messages to the Appium log
|
||||
|
||||
You can always use `console.log`, of course, but Appium provides a nice logger for you as
|
||||
`this.logger` (it has `.info`, `.debug`, `.log`, `.warn`, `.error` methods on it for differing log
|
||||
`this.log` (it has `.info`, `.debug`, `.warn`, `.error` methods on it for differing log
|
||||
levels). If you want to create an Appium logger outside of a plugin context (say in a script or
|
||||
helper file), you can always construct your own too:
|
||||
|
||||
|
||||
@@ -6,14 +6,12 @@ title: Retrieving Event Timings
|
||||
---
|
||||
|
||||
Appium comes with the ability to retrieve timing information about startup
|
||||
information and command length. This is an advanced feature that is controlled
|
||||
by the use of the `appium:eventTimings` capability (set it to `true` to log event
|
||||
timings).
|
||||
information and command length. This is an advanced feature; event and
|
||||
command timings are collected automatically for the duration of a session.
|
||||
|
||||
With this capability turned on, the `POST /session/:id/appium/events` response (i.e.,
|
||||
the response to `driver.logs.events` or similar, depending on client) will be
|
||||
decorated with an `events` property. This is the structure of that `events`
|
||||
property:
|
||||
The `POST /session/:id/appium/events` response (i.e., the response to
|
||||
`driver.logs.events` or similar, depending on client) has the following
|
||||
structure:
|
||||
|
||||
```
|
||||
{
|
||||
|
||||
@@ -117,62 +117,3 @@ hub, you'll be able to send all your Appium traffic via the single hub endpoint
|
||||
|
||||
And of course, you're able to link up Appium servers and nodes running on different machines in
|
||||
your network to form a larger grid.
|
||||
|
||||
## Using Selenium Grid 3
|
||||
|
||||
It is possible to register your Appium server with a local [Selenium Grid 3](https://www.selenium.dev/documentation/legacy/selenium_3/grid_3/)
|
||||
([setup docs](https://www.selenium.dev/documentation/legacy/grid_3/setting_up_your_own_grid/)) instance by using the
|
||||
`--nodeconfig` server argument.
|
||||
|
||||
```bash
|
||||
appium server --nodeconfig /path/to/nodeconfig.json --base-path=/wd/hub
|
||||
```
|
||||
|
||||
In the referenced config file you have to define the `browserName`, `version` and `platform`
|
||||
capabilities and based on these parameters the grid will re-direct your test to the right device.
|
||||
You will also need to configure your host details and the Selenium Grid details. For a full list of
|
||||
all parameters and descriptions see
|
||||
[here](https://www.selenium.dev/documentation/legacy/selenium_3/grid_setup/).
|
||||
|
||||
Once you start the Appium server it will register with the grid, and you will see your device on
|
||||
the grid console page:
|
||||
|
||||
`http://**\<grid-ip-adress\>**:**\<grid-port\>**/grid/console`
|
||||
|
||||
### Example Grid Node Configuration JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"capabilities":
|
||||
[
|
||||
{
|
||||
"browserName": "<e.g._iPhone5_or_iPad4>",
|
||||
"version":"<version_of_iOS_e.g._7.1>",
|
||||
"maxInstances": 1,
|
||||
"platform":"<platform_e.g._MAC_or_ANDROID>"
|
||||
}
|
||||
],
|
||||
"configuration":
|
||||
{
|
||||
"cleanUpCycle":2000,
|
||||
"timeout":30000,
|
||||
"proxy": "org.openqa.grid.selenium.proxy.DefaultRemoteProxy",
|
||||
"url":"http://<host_name_appium_server_or_ip-address_appium_server>:<appium_port>/wd/hub",
|
||||
"host": "<host_name_appium_server_or_ip-address_appium_server>",
|
||||
"port": <appium_port>,
|
||||
"maxSession": 1,
|
||||
"register": true,
|
||||
"registerCycle": 5000,
|
||||
"hubPort": <grid_port>,
|
||||
"hubHost": "<Grid_host_name_or_grid_ip-address>",
|
||||
"hubProtocol": "<Protocol_of_Grid_defaults_to_http>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `url`, `host`, and `port` are not given, the config will be auto updated to point to
|
||||
`localhost:<appium-port>`.
|
||||
|
||||
If your Appium server is running on a different machine to your Selenium Grid server, make sure you
|
||||
use an external name/IP address in your `host` and `url` configuration; `localhost` and `127.0.0.1`
|
||||
will prevent Selenium Grid from connecting correctly.
|
||||
|
||||
@@ -31,10 +31,6 @@ installing it only installs the core Appium server, without any drivers.
|
||||
|
||||
When it comes to installing Appium 2 drivers, there are several approaches you can take:
|
||||
|
||||
* Add the `--drivers` flag when installing Appium, for example:
|
||||
```bash
|
||||
npm i -g appium --drivers=xcuitest,uiautomator2
|
||||
```
|
||||
* Use the [Appium Extension CLI](../reference/cli/extensions.md), for example:
|
||||
```bash
|
||||
appium driver install uiautomator2
|
||||
|
||||
@@ -85,3 +85,118 @@ Turn on multiple insecure features for specific drivers:
|
||||
```bash
|
||||
appium --allow-insecure=uiautomator2:adb_shell,xcuitest:get_server_logs
|
||||
```
|
||||
|
||||
## Restricting Remote App URLs
|
||||
|
||||
Several drivers accept a URL instead of a local path in capabilities such as `appium:app`, in which
|
||||
case the Appium server downloads the application package from that URL before starting the session.
|
||||
By default, any `http:` or `https:` URL is accepted. Server hosts who want to control which URLs
|
||||
their server is allowed to download from can do so with the `--app-url-rules` argument (or the
|
||||
`server.app-url-rules` property in a [configuration file](./config.md)). Its value is a JSON object
|
||||
(or a path to a JSON file) with any of the following properties:
|
||||
|
||||
|<div style="width:10em">Rule</div>|Description|Default|
|
||||
|----------------------------------|-----------|-------|
|
||||
|`allow`|List of hostname patterns, IP addresses, or CIDR subnets. If non-empty, a hostname or its resolved address must match _at least one_|`[]`|
|
||||
|`deny`|List of hostname patterns, IP addresses, or CIDR subnets. A matching hostname or resolved address is rejected, even if an `allow` rule also matches|`[]`|
|
||||
|`httpsOnly`|Only accept `https:` URLs|`false`|
|
||||
|`allowCredentials`|Accept URLs containing a username and/or password (e.g. `https://user:pass@host/app.apk`)|`true`|
|
||||
|`maxRedirects`|Maximum number of HTTP redirects to follow while downloading. Set to `0` to reject any redirect|`21`|
|
||||
|
||||
Hostnames are normalized (lowercased, converted to Punycode, trailing dot removed) before matching
|
||||
and resolved on every download. Address rules apply to literal addresses and all dynamically
|
||||
resolved IPv4/IPv6 addresses. The rules are also applied to every redirect, so redirects cannot be
|
||||
used to escape them. If a URL violates any rule, the session is not created and a generic error
|
||||
is returned. The violated rule is deliberately neither reported to the client nor written to the
|
||||
server log, since server logs are often handed over to clients as well. URL schemes, ports, paths,
|
||||
queries, and fragments are not matched.
|
||||
|
||||
Hostname patterns use the [picomatch](https://github.com/micromatch/picomatch#globbing-features)
|
||||
glob syntax and are matched against the whole hostname:
|
||||
|
||||
|Pattern|Matches|
|
||||
|-------|-------|
|
||||
|`artifacts.example.com`|Exactly this hostname|
|
||||
|`*.example.com`|Any subdomain of `example.com` (including nested ones, like `a.b.example.com`), but not `example.com` itself|
|
||||
|`*example.com`|Any hostname ending with `example.com`, including `example.com` itself|
|
||||
|`apps.{staging,prod}.example.com`|`apps.staging.example.com` and `apps.prod.example.com`|
|
||||
|`build-?.example.com`|`build-1.example.com`, `build-a.example.com`, etc.|
|
||||
|`node[0-9].example.com`|`node0.example.com` to `node9.example.com`|
|
||||
|
||||
IP address rules match a single address (`192.0.2.10`, `2001:db8::1`) or a whole subnet in CIDR
|
||||
notation (`10.0.0.0/8`, `2001:db8::/32`). IPv4 rules also match IPv4-mapped IPv6 addresses
|
||||
(e.g. `::ffff:10.1.2.3`).
|
||||
|
||||
!!! note
|
||||
|
||||
These rules only apply to URLs the server downloads via the standard app configuration helper,
|
||||
which is used for `appium:app` and similar capabilities by all official drivers. Drivers may
|
||||
define custom capabilities with URLs that are processed differently and thus are not covered.
|
||||
|
||||
!!! note
|
||||
|
||||
If the server downloads applications through an HTTP proxy (configured via the `HTTP_PROXY`,
|
||||
`HTTPS_PROXY` and `NO_PROXY` environment variables), the proxy resolves the destination host
|
||||
itself, so rules containing IP addresses or subnets cannot be enforced for proxied downloads.
|
||||
Such downloads are rejected; either exclude the download hosts from proxying via `NO_PROXY`
|
||||
or only use hostname-based rules.
|
||||
|
||||
### Examples
|
||||
|
||||
Only allow HTTPS downloads from an internal artifact server, without credentials in the URL and
|
||||
without following redirects:
|
||||
|
||||
```bash
|
||||
appium --app-url-rules='{"allow": ["artifacts.example.com", "10.0.0.0/8"], "httpsOnly": true, "allowCredentials": false, "maxRedirects": 0}'
|
||||
```
|
||||
|
||||
The same configuration as part of a config file:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
app-url-rules:
|
||||
allow:
|
||||
- 'artifacts.example.com'
|
||||
- '10.0.0.0/8'
|
||||
httpsOnly: true
|
||||
allowCredentials: false
|
||||
maxRedirects: 0
|
||||
```
|
||||
|
||||
Allow downloads from any subdomain of `example.com` and from two IPv6 subnets, except from the
|
||||
staging environment:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
app-url-rules:
|
||||
allow:
|
||||
- '*.example.com'
|
||||
- '2001:db8:1::/48'
|
||||
- '2001:db8:2::/48'
|
||||
deny:
|
||||
- '*.staging.example.com'
|
||||
```
|
||||
|
||||
Allow downloads from anywhere, except from the server itself, private networks, link-local
|
||||
addresses (such as cloud metadata services), and any `.internal` hostname:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
app-url-rules:
|
||||
deny:
|
||||
- 'localhost'
|
||||
- '*.internal'
|
||||
- '127.0.0.0/8'
|
||||
- '10.0.0.0/8'
|
||||
- '172.16.0.0/12'
|
||||
- '192.168.0.0/16'
|
||||
- '169.254.0.0/16'
|
||||
- '::1/128'
|
||||
- 'fc00::/7'
|
||||
- 'fe80::/10'
|
||||
```
|
||||
|
||||
The rules in the last example are applied to the addresses each hostname resolves to, so they
|
||||
cannot be bypassed via DNS. Note that the `deny` list also blocks any download from the server's
|
||||
own private network, so a private artifact server must be excluded from the `deny` rules in order
|
||||
to work.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
hide:
|
||||
- toc
|
||||
|
||||
title: SSL/TLS/SPDY Support
|
||||
title: SSL/TLS Support
|
||||
---
|
||||
|
||||
Appium 2.2 introduces the option to start the Appium server with SSL/TLS support.
|
||||
@@ -19,15 +19,14 @@ Both arguments must be provided and should contain paths to a valid
|
||||
[X509 PEM](https://www.ssl.com/guide/pem-der-crt-and-cer-x-509-encodings-and-conversions/)
|
||||
certificate and its corresponding private key.
|
||||
|
||||
After the server is started use the `https` protocol and a client supporting SSL/TLS or
|
||||
[SPDY](https://en.wikipedia.org/wiki/SPDY) to communicate to it.
|
||||
After the server is started use the `https` protocol and a client supporting SSL/TLS to
|
||||
communicate to it.
|
||||
|
||||
### Supported Features
|
||||
|
||||
Once a secure server socket is established it supports the following protocols:
|
||||
`['h2', 'spdy/3.1', 'spdy/3', 'spdy/2', 'http/1.1', 'http/1.0']`. See
|
||||
[the SPDY node module documentation](https://www.npmjs.com/package/spdy) to get more details about
|
||||
its features. All insecure client connections will be rejected by the server.
|
||||
Once a secure server socket is established it uses Node.js' native
|
||||
[`https`](https://nodejs.org/api/https.html) module to serve connections. All insecure client
|
||||
connections will be rejected by the server.
|
||||
|
||||
### Self-Signed Certificates
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ So, for example, the `Find Element` command corresponds to an HTTP `POST` reques
|
||||
endpoint `/session/:sessionid/element` (where in this case, `:sessionid` is a placeholder for the
|
||||
unique session ID generated by the server in a previous call to `Create Session`).
|
||||
|
||||
If the client disconnects before Appium finishes sending a `Create Session` response and the
|
||||
request has no `X-Idempotency-Key`, Appium deletes the new session once driver setup completes.
|
||||
Setup is not interrupted. With an accepted idempotency key, a retry that arrives before setup
|
||||
finishes waits for the same session ID, or the same error if setup failed. If the original client
|
||||
and all waiting retries disconnect before setup finishes, Appium deletes the new session and
|
||||
discards its cached result; a later retry starts a new session. Otherwise, Appium keeps the session
|
||||
and caches its response for replay while it remains in the bounded, in-memory idempotency cache.
|
||||
Disconnecting after a response was sent, or during a command in an existing session, does not
|
||||
automatically end that session. Use `Delete Session` when you are done.
|
||||
|
||||
This information is primarily useful for people developing technology that works with the WebDriver
|
||||
spec. It's not particularly useful for people trying to write Appium or Selenium tests. When you
|
||||
write an Appium test, you want to use a programming language you're familiar with. Luckily, there
|
||||
|
||||
@@ -19,7 +19,7 @@ Appium, called `BaseDriver`. You could have something very close to a "working"
|
||||
very simple lines of code:
|
||||
|
||||
```js
|
||||
import BaseDriver from '@appium/base-driver'
|
||||
import {BaseDriver} from '@appium/base-driver'
|
||||
|
||||
class MyNewDriver extends BaseDriver {
|
||||
}
|
||||
|
||||
@@ -10,125 +10,6 @@ title: JSON Wire Protocol
|
||||
The following is a list of legacy [JSON Wire Protocol (JSONWP)](https://www.selenium.dev/documentation/legacy/json_wire_protocol/)
|
||||
endpoints supported in Appium.
|
||||
|
||||
### getSession
|
||||
|
||||
```
|
||||
GET /session/:sessionId
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionid)
|
||||
|
||||
Retrieves the capabilities of the current session.
|
||||
|
||||
Appium implements a modified version of this endpoint. If the `appium:eventTimings` capability is
|
||||
set to `true`, the returned result will additionally include the `events` key, whose value contains
|
||||
the event history and timings.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
For retrieving capabilities, please use [`getAppiumSessionCapabilities`](./appium.md#getappiumsessioncapabilities).<br />
|
||||
For retrieving event history, please use [`getLogEvents`](./appium.md#getlogevents).
|
||||
|
||||
#### Response
|
||||
|
||||
`Capabilities` - an object containing the session capabilities, and the event history (if applicable)
|
||||
|
||||
### availableIMEEngines
|
||||
|
||||
```
|
||||
GET /session/:sessionId/ime/available_engines
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/ime/available_engines](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidimeavailable_engines)
|
||||
|
||||
Retrieves all IME (input method editor) engines available on the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
In the future, this endpoint will be moved to the [UiAutomator2 and Espresso drivers](../../ecosystem/drivers.md)
|
||||
|
||||
#### Response
|
||||
|
||||
`string[]` - a list of available IME engines
|
||||
|
||||
### getActiveIMEEngine
|
||||
|
||||
```
|
||||
GET /session/:sessionId/ime/active_engine
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/ime/active_engine](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidimeactive_engine)
|
||||
|
||||
Retrieves the name of the active IME engine.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
In the future, this endpoint will be moved to the UiAutomator2 and Espresso drivers
|
||||
|
||||
#### Response
|
||||
|
||||
`string` - the name of the active IME engine
|
||||
|
||||
### isIMEActivated
|
||||
|
||||
```
|
||||
GET /session/:sessionId/ime/activated
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/ime/activated](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidimeactivated)
|
||||
|
||||
Determines if IME input is available and active.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
In the future, this endpoint will be moved to the UiAutomator2 and Espresso drivers
|
||||
|
||||
#### Response
|
||||
|
||||
`boolean` - `true` if IME is active, otherwise `false`
|
||||
|
||||
### deactivateIMEEngine
|
||||
|
||||
```
|
||||
POST /session/:sessionId/ime/deactivate
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/ime/deactivate](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidimedeactivate)
|
||||
|
||||
Deactivates the currently active IME engine.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
In the future, this endpoint will be moved to the UiAutomator2 and Espresso drivers
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### activateIMEEngine
|
||||
|
||||
```
|
||||
POST /session/:sessionId/ime/activate
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/ime/activate](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidimeactivate)
|
||||
|
||||
Activates an IME engine.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
In the future, this endpoint will be moved to the UiAutomator2 and Espresso drivers
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`engine`|Name of the IME engine to activate|string|
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### getOrientation
|
||||
|
||||
```
|
||||
@@ -170,53 +51,3 @@ Sets the orientation of the device under test.
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
### getGeoLocation
|
||||
|
||||
```
|
||||
GET /session/:sessionId/location
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/location](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidlocation)
|
||||
|
||||
Retrieves the current location of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use driver-specific extension methods such as `mobile: getGeoLocation` or
|
||||
`mobile: getSimulatedLocation`
|
||||
|
||||
#### Response
|
||||
|
||||
`Location` - an object with the following properties:
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`altitude`|Altitude of the device location|number|
|
||||
|`latitude`|Latitude of the device location|number|
|
||||
|`longitude`|Longitude of the device location|number|
|
||||
|
||||
### setGeoLocation
|
||||
|
||||
```
|
||||
POST /session/:sessionId/location
|
||||
```
|
||||
|
||||
> JSONWP documentation: [/session/:sessionId/location](https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidlocation)
|
||||
|
||||
Sets the current location of the device under test.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use driver-specific extension methods such as `mobile: setGeoLocation` or
|
||||
`mobile: setSimulatedLocation`
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`location`|New device latitude, longitude and altitude|[`Location`](#response_6)|
|
||||
|
||||
#### Response
|
||||
|
||||
`null`
|
||||
|
||||
@@ -119,53 +119,3 @@ Retrieves all available application contexts.
|
||||
#### Response
|
||||
|
||||
`string[]` - the names of available contexts
|
||||
|
||||
### getNetworkConnection
|
||||
|
||||
```
|
||||
GET /session/:sessionId/network_connection
|
||||
```
|
||||
|
||||
> MJSONWP documentation: [Device Modes](https://github.com/SeleniumHQ/mobile-spec/blob/master/spec-draft.md#device-modes)
|
||||
|
||||
Retrieves the current state of network types (data, Wi-Fi, airplane mode).
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use driver-specific extension methods such as `mobile: getConnectivity`
|
||||
|
||||
#### Response
|
||||
|
||||
`NetworkConnectionState` - a number indicating the current network state:
|
||||
|
||||
|Value|Data|Wi-Fi|Airplane Mode|
|
||||
|--|--|--|--|
|
||||
|`0`|OFF|OFF|OFF|
|
||||
|`1`|OFF|OFF|ON|
|
||||
|`2`|OFF|ON|OFF|
|
||||
|`4`|ON|OFF|OFF|
|
||||
|`6`|ON|ON|OFF|
|
||||
|
||||
### setNetworkConnection
|
||||
|
||||
```
|
||||
POST /session/:sessionId/network_connection
|
||||
```
|
||||
|
||||
> MJSONWP documentation: [Device Modes](https://github.com/SeleniumHQ/mobile-spec/blob/master/spec-draft.md#device-modes)
|
||||
|
||||
Sets the state of network types (data, Wi-Fi, airplane mode).
|
||||
|
||||
!!! warning "Deprecated"
|
||||
|
||||
Please use driver-specific extension methods such as `mobile: setConnectivity`
|
||||
|
||||
#### Parameters
|
||||
|
||||
|<div style="width:6em">Name</div>|Description|<div style="width:18em">Type</div>|
|
||||
|--|--|--|
|
||||
|`parameters`|Object containing the `type` key, whose value is the desired network state|`{"type": `[`NetworkConnectionState`](#response_5)`}`|
|
||||
|
||||
#### Response
|
||||
|
||||
[`NetworkConnectionState`](#response_5) - the new network state
|
||||
|
||||
@@ -25,18 +25,11 @@ POST /session
|
||||
|
||||
Creates a new WebDriver session.
|
||||
|
||||
Appium implements a modified version of this endpoint for historical reasons. While the W3C
|
||||
endpoint only accepts 1 parameter, Appium's implementation allows up to 3 parameters, as this was
|
||||
required by the legacy JSON Wire Protocol (JSONWP). Since Appium 2, the JSONWP format is no longer
|
||||
supported, and any of the 3 parameters can be used to specify the W3C capabilities.
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`w3cCapabilities1?`|Capabilities of the new session|`W3CDriverCaps`|
|
||||
|`w3cCapabilities2?`|Another location for the new session capabilities (legacy)|`W3CDriverCaps`|
|
||||
|`w3cCapabilities?`|Another location for the new session capabilities (legacy)|`W3CDriverCaps`|
|
||||
|`capabilities?`|Capabilities of the new session|`W3CDriverCaps`|
|
||||
|
||||
#### Response
|
||||
|
||||
@@ -91,14 +84,19 @@ GET /session/:sessionId/timeouts
|
||||
|
||||
Retrieves the timeout values of the current session.
|
||||
|
||||
Appium implements a modified version of this endpoint, by also returning the value of the `command`
|
||||
timeout for supported drivers and contexts.
|
||||
|
||||
#### Response
|
||||
|
||||
`GetTimeoutsResult` - an object with the following properties:
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`command`|Command timeout|number|
|
||||
|`script`|Script timeout|number|
|
||||
|`pageLoad`|Page load timeout|number|
|
||||
|`implicit`|Implicit wait timeout|number|
|
||||
|`command`|Appium command timeout|number|
|
||||
|
||||
### timeouts
|
||||
|
||||
@@ -110,13 +108,17 @@ POST /session/:sessionId/timeouts
|
||||
|
||||
Sets the timeout values of the current session.
|
||||
|
||||
Appium implements a modified version of this endpoint by adding the optional `command` parameter
|
||||
for supported drivers and contexts.
|
||||
|
||||
#### Parameters
|
||||
|
||||
|Name|Description|Type|
|
||||
|--|--|--|
|
||||
|`script?`|Script timeout (in milliseconds)|number|
|
||||
|`implicit?`|Implicit wait timeout (in milliseconds)|number|
|
||||
|`pageLoad?`|Page load timeout (in milliseconds)|number|
|
||||
|`script?`|Script timeout (in milliseconds)|number|
|
||||
|`command?`|Appium command timeout (in milliseconds)|number|
|
||||
|
||||
#### Response
|
||||
|
||||
|
||||
@@ -13,12 +13,6 @@ 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
|
||||
@@ -37,7 +31,7 @@ appium {driver|plugin} doctor <extension-name>
|
||||
|
||||
|Argument|Description|
|
||||
|--|--|
|
||||
|`extension-name`|The short name or exact npm package name of the installed extension|
|
||||
|`extension-name`|The short name of the installed extension|
|
||||
|
||||
#### Options
|
||||
|
||||
@@ -169,7 +163,7 @@ appium {driver|plugin} run <extension-name> [<script-name> [<script-args>]]
|
||||
|
||||
|Argument|Description|
|
||||
|--|--|
|
||||
|`extension-name`|The short name or exact npm package name of the installed extension|
|
||||
|`extension-name`|The short name of the installed extension|
|
||||
|`script-name`|The name of the script to run. If not provided, a list of available scripts is returned.|
|
||||
|`script-args`|Any additional arguments passed to the script|
|
||||
|
||||
@@ -206,7 +200,7 @@ appium {driver|plugin} update <extension-name>
|
||||
|
||||
|Argument|Description|
|
||||
|--|--|
|
||||
|`extension-name`|The short name or exact npm package name of the installed extension, or `installed` to update all installed extensions|
|
||||
|`extension-name`|The short name of the installed extension, or `installed` to update all installed extensions|
|
||||
|
||||
#### Options
|
||||
|
||||
@@ -241,7 +235,7 @@ appium {driver|plugin} uninstall <extension-name>
|
||||
|
||||
|Argument|Description|
|
||||
|--|--|
|
||||
|`extension-name`|The short name or exact npm package name of the installed extension|
|
||||
|`extension-name`|The short name of the installed extension|
|
||||
|
||||
#### Options
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ they can only be enabled using the wildcard (`*`) prefix:
|
||||
| Feature Name | Description |
|
||||
|-------------------|-------------------------------------------------------------------------------|
|
||||
|`session_discovery`|Allows retrieving the list of active server sessions via `GET /appium/sessions`|
|
||||
|`cors` |Allows web browser connections to the server from any host |
|
||||
|
||||
|
||||
Of course, Appium drivers and plugins are free to define additional insecure features of their own.
|
||||
|
||||
@@ -27,9 +27,9 @@ appium
|
||||
|<div style="width:15em">Argument</div>|Description|Type|<div style="width:7em">Default</div>|
|
||||
|--|--|--|--|
|
||||
|`--address`, `-a`|IPv4/IPv6 address to listen on|string|`0.0.0.0`|
|
||||
|`--allow-cors`|Allow web browser connections from any host|boolean|`false`|
|
||||
|`--allow-insecure`|List of [insecure features](../../guides/security.md) that should be allowed in this server's sessions. Individual features can be overridden by `--deny-insecure`. Has no effect in combination with `--relaxed-security`.|array<string>|`[]`|
|
||||
|`--allow-unknown-args`|Do not exit if unrecognized command-line arguments are passed to the server; ignore them instead. Useful when the Appium CLI is wrapped by external tooling that appends extra flags.|boolean|`false`|
|
||||
|`--app-url-rules`|Rules that remote application URLs (e.g. in the `appium:app` capability) must satisfy before the server downloads them. See the [Restricting Remote App URLs](../../guides/security.md#restricting-remote-app-urls) guide for details. Can be a path to a JSON file or raw JSON.|object||
|
||||
|`--base-path`, `-pa`|Base path to use as the prefix for all webdriver routes running on the server|string|`""`|
|
||||
|`--callback-address`, `-ca`|Callback IP address|string|`0.0.0.0`|
|
||||
|`--callback-port`, `-cp`|Callback port|integer|`4723`|
|
||||
@@ -44,7 +44,7 @@ appium
|
||||
|`--local-timezone`|Use local timezone for log timestamps|boolean|`false`|
|
||||
|`--log`, `-g`|Path to a file where the server logs should be output. This does not affect output on the console.|string||
|
||||
|`--log-filters`|List of log filtering rules. See the [log filtering guide](../../guides/log-filters.md) for details.|array||
|
||||
|`--log-level`|The log level for the server logs. Supported values are `debug`, `info`, `warn`, or `error`. Combining two supported values using a colon (e.g. `warn:debug`) allows to set separate log levels for the console and file outputs, respectively.|string|`debug`|
|
||||
|`--log-level`|The log level for the server logs. Supported values are `debug`, `info`, `warn`, or `error`. Combining two supported values using a colon (e.g. `warn:debug`) allows to set separate log levels for the console and file outputs, respectively.|string|`info`|
|
||||
|`--log-format`|The log format of the server logs. Supported values are `text`, `json`, or `pretty_json`. Setting the value to `json` disables colors.|string|`text`|
|
||||
|`--log-no-colors`|Disable colors in the server log|boolean|`false`|
|
||||
|`--log-timestamp`|Show timestamps in the server log|boolean|`false`|
|
||||
@@ -52,7 +52,6 @@ appium
|
||||
|`--max-ipc-data-size`|Maximum byte size of IPC message objects.|integer|`1048576` (1MB)|
|
||||
|`--max-ipc-topics`|Maximum number of IPC topics per session.|integer|`1000`|
|
||||
|`--no-perms-check`|Skip various permission checks on the server startup|boolean|`false`|
|
||||
|`--nodeconfig`|JSON configuration for registering Appium as a node with Selenium Grid 3|object||
|
||||
|`--plugin`|Plugin-specific configuration. Keys should correspond to plugin package names|object||
|
||||
|`--plugins-import-chunk-size`|Maximum number of plugins that can be imported in parallel on server startup|number|`7`|
|
||||
|`--port`, `-p`|Port to listen on|integer|`4723`|
|
||||
|
||||
@@ -32,7 +32,6 @@ default value.
|
||||
| <div style="width:19em">Capability</div> | Description | Type | Default |
|
||||
|--|--|--|--|
|
||||
| `webSocketUrl` | Toggle support of the WebDriver BiDi protocol | `boolean` ||
|
||||
| `appium:eventTimings` (deprecated) | Toggle collection of [Event Timings](../../guides/event-timing.md). This capability is deprecated - please use the [`getLogEvents`](../api/appium.md#getlogevents) endpoint instead. | `boolean` ||
|
||||
| `appium:newCommandTimeout` | Number of seconds the Appium server should wait for clients to send commands before stopping the session. A value of `0` disables the timeout. | `number` | `60` |
|
||||
| `appium:printPageSourceOnFindFailure` | Toggle retrieval of the the page source and its printing to the Appium log, whenever a request to find an element fails | `boolean` ||
|
||||
|
||||
|
||||
@@ -14,12 +14,6 @@ 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
|
||||
@@ -36,9 +30,9 @@ configured correctly. Note that not all extensions include doctor checks.
|
||||
appium {driver|plugin} doctor <extension-name>
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
| ---------------- | ------------------------------------------------------------------- |
|
||||
| `extension-name` | The short name or exact npm package name of the installed extension |
|
||||
| Argument | Description |
|
||||
| ---------------- | ----------------------------------------- |
|
||||
| `extension-name` | The short name of the installed extension |
|
||||
|
||||
#### Options
|
||||
|
||||
@@ -170,7 +164,7 @@ appium {driver|plugin} run <extension-name> [<script-name> [<script-args>]]
|
||||
|
||||
| Argument | Description |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `extension-name` | The short name or exact npm package name of the installed extension |
|
||||
| `extension-name` | The short name of the installed extension |
|
||||
| `script-name` | The name of the script to run. If not provided, a list of available scripts is returned. |
|
||||
| `script-args` | Any additional arguments passed to the script |
|
||||
|
||||
@@ -205,9 +199,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 or exact npm package name of the installed extension, or `installed` to update all installed extensions |
|
||||
| Argument | Description |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `extension-name` | The short name of the installed extension, or `installed` to update all installed extensions |
|
||||
|
||||
#### Options
|
||||
|
||||
@@ -240,9 +234,9 @@ Removes an installed extension.
|
||||
appium {driver|plugin} uninstall <extension-name>
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
| ---------------- | ------------------------------------------------------------------- |
|
||||
| `extension-name` | The short name or exact npm package name of the installed extension |
|
||||
| Argument | Description |
|
||||
| ---------------- | ----------------------------------------- |
|
||||
| `extension-name` | The short name of the installed extension |
|
||||
|
||||
#### Options
|
||||
|
||||
|
||||
@@ -10,18 +10,16 @@
|
||||
/* eslint-disable promise/prefer-await-to-callbacks */
|
||||
/* eslint-disable promise/prefer-await-to-then */
|
||||
|
||||
const {
|
||||
log,
|
||||
LANGS,
|
||||
DOCS_DIR,
|
||||
DOCS_BRANCH,
|
||||
DOCS_PREFIX,
|
||||
DOCS_REMOTE,
|
||||
LATEST_ALIAS,
|
||||
} = require('./utils');
|
||||
const path = require('node:path');
|
||||
const semver = require('semver');
|
||||
const {version} = require('../../package.json');
|
||||
import {readFileSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
import {deploy, buildSite} from '@appium/docutils';
|
||||
import semver from 'semver';
|
||||
|
||||
import {log, LANGS, DOCS_DIR, DOCS_BRANCH, DOCS_PREFIX, DOCS_REMOTE, LATEST_ALIAS} from './utils.js';
|
||||
|
||||
const {version} = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
||||
|
||||
const branch = process.env.APPIUM_DOCS_BRANCH ?? DOCS_BRANCH;
|
||||
const basePrefix = process.env.APPIUM_DOCS_PREFIX ?? DOCS_PREFIX;
|
||||
@@ -34,9 +32,6 @@ const push = Boolean(process.env.APPIUM_DOCS_PUBLISH);
|
||||
* Builds and optionally deploys docs for each supported language.
|
||||
*/
|
||||
async function main() {
|
||||
// @appium/docutils is ESM-only; this file is CommonJS, so it must be loaded dynamically
|
||||
const {deploy, buildSite} = await import('@appium/docutils');
|
||||
|
||||
log.info(`Building Appium docs and committing to ${DOCS_BRANCH}`);
|
||||
|
||||
const semVersion = semver.parse(version);
|
||||
@@ -69,10 +64,10 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
main().catch((err) => {
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = main;
|
||||
export default main;
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
/* eslint-disable promise/prefer-await-to-callbacks */
|
||||
/* eslint-disable promise/prefer-await-to-then */
|
||||
|
||||
const path = require('node:path');
|
||||
const {fs} = require('@appium/support');
|
||||
const {AppiumConfigJsonSchema} = require('@appium/schema');
|
||||
import {realpathSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const DOC_PATH = path.resolve(__dirname, '..', 'en', 'cli', 'args.md');
|
||||
import {AppiumConfigJsonSchema} from '@appium/schema';
|
||||
import {fs} from '@appium/support';
|
||||
|
||||
const DOC_PATH = path.resolve(import.meta.dirname, '..', 'en', 'cli', 'args.md');
|
||||
|
||||
const START_TAG = `<!-- AUTOGEN-START -->`;
|
||||
const STOP_TAG = `<!-- AUTOGEN-STOP -->`;
|
||||
@@ -89,10 +92,10 @@ async function writeNewDoc(preStart, argTable, postEnd) {
|
||||
await fs.writeFile(DOC_PATH, newContents);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
|
||||
main().catch((err) => {
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = main;
|
||||
export default main;
|
||||
|
||||
@@ -2,28 +2,19 @@
|
||||
|
||||
// for simplicity this file is not transpiled and is run directly via an npm script
|
||||
//
|
||||
const {logger} = require('@appium/support');
|
||||
const path = require('node:path');
|
||||
import path from 'node:path';
|
||||
|
||||
import {logger} from '@appium/support';
|
||||
|
||||
const log = logger.getLogger('Docs');
|
||||
|
||||
const DOCS_REMOTE = 'origin';
|
||||
const DOCS_BRANCH = 'gh-pages';
|
||||
const DOCS_PREFIX = 'docs';
|
||||
const DOCS_DIR = path.resolve(__dirname, '..');
|
||||
const DOCS_DIR = path.resolve(import.meta.dirname, '..');
|
||||
const LATEST_ALIAS = 'latest';
|
||||
const ASSETS_DIR = path.join(DOCS_DIR, 'assets');
|
||||
const LANGS = ['en', 'ja', 'zh'];
|
||||
const DEFAULT_LANG = 'en';
|
||||
|
||||
module.exports = {
|
||||
log,
|
||||
DOCS_DIR,
|
||||
ASSETS_DIR,
|
||||
LANGS,
|
||||
DEFAULT_LANG,
|
||||
DOCS_BRANCH,
|
||||
DOCS_PREFIX,
|
||||
DOCS_REMOTE,
|
||||
LATEST_ALIAS,
|
||||
};
|
||||
export {log, DOCS_DIR, ASSETS_DIR, LANGS, DEFAULT_LANG, DOCS_BRANCH, DOCS_PREFIX, DOCS_REMOTE, LATEST_ALIAS};
|
||||
|
||||
@@ -14,12 +14,6 @@ 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
|
||||
@@ -36,9 +30,9 @@ configured correctly. Note that not all extensions include doctor checks.
|
||||
appium {driver|plugin} doctor <extension-name>
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
| ---------------- | ------------------------------------------------------------------- |
|
||||
| `extension-name` | The short name or exact npm package name of the installed extension |
|
||||
| Argument | Description |
|
||||
| ---------------- | ----------------------------------------- |
|
||||
| `extension-name` | The short name of the installed extension |
|
||||
|
||||
#### Options
|
||||
|
||||
@@ -170,7 +164,7 @@ appium {driver|plugin} run <extension-name> [<script-name> [<script-args>]]
|
||||
|
||||
| Argument | Description |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `extension-name` | The short name or exact npm package name of the installed extension |
|
||||
| `extension-name` | The short name of the installed extension |
|
||||
| `script-name` | The name of the script to run. If not provided, a list of available scripts is returned. |
|
||||
| `script-args` | Any additional arguments passed to the script |
|
||||
|
||||
@@ -205,9 +199,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 or exact npm package name of the installed extension, or `installed` to update all installed extensions |
|
||||
| Argument | Description |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `extension-name` | The short name of the installed extension, or `installed` to update all installed extensions |
|
||||
|
||||
#### Options
|
||||
|
||||
@@ -240,9 +234,9 @@ Removes an installed extension.
|
||||
appium {driver|plugin} uninstall <extension-name>
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
| ---------------- | ------------------------------------------------------------------- |
|
||||
| `extension-name` | The short name or exact npm package name of the installed extension |
|
||||
| Argument | Description |
|
||||
| ---------------- | ----------------------------------------- |
|
||||
| `extension-name` | The short name of the installed extension |
|
||||
|
||||
#### Options
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
@@ -7,8 +5,6 @@
|
||||
*
|
||||
* @see https://npm.im/@appium/base-driver
|
||||
* @example
|
||||
* const { BaseDriver, errors } = require('appium/driver');
|
||||
* import { BaseDriver, errors } from 'appium/driver.js';
|
||||
*/
|
||||
|
||||
/** @type {import('@appium/base-driver')} */
|
||||
module.exports = require('@appium/base-driver');
|
||||
export * from '@appium/base-driver';
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const appium = require('./build/lib/main.js');
|
||||
import {realpathSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
if (require.main === module) {
|
||||
import * as appium from './build/lib/main.js';
|
||||
|
||||
// `process.argv[1]` retains the invoked path as-is (e.g. an npm-created bin symlink on Unix),
|
||||
// while `import.meta.url` always reflects the resolved real path; realpath() both sides so this
|
||||
// still matches when `appium` is launched via its installed bin symlink.
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
|
||||
appium.main();
|
||||
}
|
||||
|
||||
module.exports = appium;
|
||||
export * from './build/lib/main.js';
|
||||
|
||||
+48
-117
@@ -4,7 +4,6 @@ import {
|
||||
CREATE_SESSION_COMMAND,
|
||||
DELETE_SESSION_COMMAND,
|
||||
DriverCore,
|
||||
errors,
|
||||
type ExtensionCore,
|
||||
generateDriverLogPrefix,
|
||||
GET_STATUS_COMMAND,
|
||||
@@ -20,7 +19,6 @@ import {util} from '@appium/support';
|
||||
import type {
|
||||
AppiumServer,
|
||||
DriverCaps,
|
||||
DriverData,
|
||||
DriverOpts,
|
||||
ExternalDriver,
|
||||
IAppiumIpc,
|
||||
@@ -35,15 +33,20 @@ import type {
|
||||
} from '@appium/types';
|
||||
import type WebSocket from 'ws';
|
||||
|
||||
import * as bidiCommands from './bidi-commands';
|
||||
import {BIDI_BASE_PATH, DRIVER_TYPE, SESSION_DISCOVERY_FEATURE} from './constants';
|
||||
import type {DriverConfig} from './extension/driver-config';
|
||||
import {APPIUM_VER, getBuildInfo, updateBuildInfo} from './helpers/build';
|
||||
import {makeNonW3cCapsError, parseCapsForInnerDriver, type ParsedDriverCaps, pullSettings} from './helpers/capability';
|
||||
import * as insecureFeatures from './insecure-features';
|
||||
import * as inspectorCommands from './inspector-commands';
|
||||
import {getDefaultsForExtension} from './schema';
|
||||
import {compact, pickBy, pull} from './utils';
|
||||
import * as bidiCommands from './bidi-commands.js';
|
||||
import {BIDI_BASE_PATH, DRIVER_TYPE, SESSION_DISCOVERY_FEATURE} from './constants.js';
|
||||
import type {DriverConfig} from './extension/driver-config.js';
|
||||
import {APPIUM_VER, getBuildInfo, updateBuildInfo} from './helpers/build.js';
|
||||
import {
|
||||
makeNonW3cCapsError,
|
||||
parseCapsForInnerDriver,
|
||||
type ParsedDriverCaps,
|
||||
pullSettings,
|
||||
} from './helpers/capability.js';
|
||||
import * as insecureFeatures from './insecure-features.js';
|
||||
import * as inspectorCommands from './inspector-commands.js';
|
||||
import {getDefaultsForExtension} from './schema/index.js';
|
||||
import {pickBy} from './utils/index.js';
|
||||
|
||||
const desiredCapabilityConstraints = {
|
||||
automationName: {
|
||||
@@ -59,6 +62,14 @@ const desiredCapabilityConstraints = {
|
||||
export type AppiumDriverConstraints = typeof desiredCapabilityConstraints;
|
||||
export type W3CAppiumDriverCaps = W3CDriverCaps<AppiumDriverConstraints>;
|
||||
|
||||
/**
|
||||
* {@link DriverCaps} with `webSocketUrl` widened to `string`: the client sends it as a boolean,
|
||||
* but the inner driver's response carries the resolved BiDi URL as a string.
|
||||
*/
|
||||
type DriverCapsWithBidiUrl = Omit<DriverCaps<AppiumDriverConstraints>, 'webSocketUrl'> & {
|
||||
webSocketUrl?: string | boolean;
|
||||
};
|
||||
|
||||
/** Result shape for umbrella {@link AppiumDriver.createSession} / {@link AppiumDriver.deleteSession}. */
|
||||
interface SessionHandlerResult<V = unknown> {
|
||||
value?: V;
|
||||
@@ -66,28 +77,13 @@ interface SessionHandlerResult<V = unknown> {
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
type SessionHandlerCreateResult = SessionHandlerResult<
|
||||
[string, DriverCaps<AppiumDriverConstraints>, string | undefined]
|
||||
>;
|
||||
type SessionHandlerCreateResult = SessionHandlerResult<[string, DriverCapsWithBidiUrl, string | undefined]>;
|
||||
|
||||
type SessionHandlerDeleteResult = SessionHandlerResult<void>;
|
||||
|
||||
/**
|
||||
* Tuple shape of the deprecated multi-argument overload of {@link AppiumDriver.createSession}.
|
||||
* Shared between that overload's declaration and its implementation signature so the parameter
|
||||
* list only needs to be written out once.
|
||||
*
|
||||
* @deprecated Use the single-argument overload of {@link AppiumDriver.createSession} instead.
|
||||
*/
|
||||
type LegacyCreateSessionArgs = [
|
||||
w3cCapabilities1: W3CAppiumDriverCaps,
|
||||
w3cCapabilities2?: W3CAppiumDriverCaps,
|
||||
w3cCapabilities3?: W3CAppiumDriverCaps,
|
||||
];
|
||||
|
||||
/** @internal Not part of {@link ExternalDriver}; used only when wiring session IPC. */
|
||||
type IpcAssignable = {
|
||||
assignIpc?: (ipc: IAppiumIpc) => Promise<void>;
|
||||
assignIpc: (ipc: IAppiumIpc) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -97,8 +93,6 @@ type IpcAssignable = {
|
||||
export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
readonly sessions: Record<string, ExternalDriver> = {};
|
||||
|
||||
readonly pendingDrivers: Record<string, ExternalDriver[]> = {};
|
||||
|
||||
/**
|
||||
* The umbrella driver does not observe its own command timeout; inner session drivers do.
|
||||
*/
|
||||
@@ -169,7 +163,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
}
|
||||
|
||||
/** The umbrella driver does not queue commands; inner session drivers may. */
|
||||
get isCommandsQueueEnabled(): boolean {
|
||||
override get isCommandsQueueEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -278,23 +272,15 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
* Creates a session: picks an inner driver from caps, runs plugin hooks, and returns a protocol
|
||||
* envelope with either `[sessionId, caps, protocol]` or an error.
|
||||
*
|
||||
* @param w3cCapabilities - the new session capabilities in W3C format
|
||||
* @param rawW3cCapabilities - the new session capabilities in W3C format
|
||||
*/
|
||||
async createSession(w3cCapabilities: W3CAppiumDriverCaps): Promise<SessionHandlerCreateResult>;
|
||||
/**
|
||||
* @deprecated Legacy call sites may pass the same W3C caps in up to three positions. These
|
||||
* positions are intended to carry the same value; if they differ, which one wins is
|
||||
* unspecified. Use the single-argument overload of {@linkcode createSession} instead.
|
||||
*/
|
||||
async createSession(...legacyArgs: LegacyCreateSessionArgs): Promise<SessionHandlerCreateResult>;
|
||||
async createSession(...legacyArgs: LegacyCreateSessionArgs): Promise<SessionHandlerCreateResult> {
|
||||
const [w3cCapabilities1, w3cCapabilities2, w3cCapabilities3] = legacyArgs;
|
||||
async createSession(rawW3cCapabilities: W3CAppiumDriverCaps): Promise<SessionHandlerCreateResult> {
|
||||
const defaultCapabilities = structuredClone(this.args.defaultCapabilities);
|
||||
const defaultSettings = pullSettings((defaultCapabilities ?? {}) as StringRecord);
|
||||
const w3cCapabilities = structuredClone([w3cCapabilities3, w3cCapabilities2, w3cCapabilities1].find(isW3cCaps));
|
||||
if (!w3cCapabilities) {
|
||||
if (!isW3cCaps(rawW3cCapabilities)) {
|
||||
throw makeNonW3cCapsError();
|
||||
}
|
||||
const w3cCapabilities = structuredClone(rawW3cCapabilities);
|
||||
const w3cSettings = {
|
||||
...defaultSettings,
|
||||
...pullSettings(w3cCapabilities.alwaysMatch ?? {}),
|
||||
@@ -305,7 +291,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
|
||||
const protocol = PROTOCOLS.W3C;
|
||||
let innerSessionId: string;
|
||||
let dCaps: DriverCaps<AppiumDriverConstraints> & {webSocketUrl?: string | boolean};
|
||||
let dCaps: DriverCapsWithBidiUrl;
|
||||
try {
|
||||
// Parse the caps into a format that the InnerDriver will accept
|
||||
const parsedCaps = parseCapsForInnerDriver<AppiumDriverConstraints>(
|
||||
@@ -330,9 +316,6 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
await this.deleteAllSessions();
|
||||
}
|
||||
|
||||
let runningDriversData: DriverData[] = [];
|
||||
let otherPendingDriversData: DriverData[] = [];
|
||||
|
||||
const driverInstance = new InnerDriver(this.args, true) as unknown as ExternalDriver;
|
||||
|
||||
this.configureDriverFeatures(driverInstance, driverName);
|
||||
@@ -360,38 +343,19 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
driverInstance.serverPort = this.args.port;
|
||||
driverInstance.serverPath = this.args.basePath;
|
||||
|
||||
try {
|
||||
runningDriversData = (await this.curSessionDataForDriver(InnerDriver)) ?? [];
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new errors.SessionNotCreatedError(msg);
|
||||
}
|
||||
this.pendingDrivers[InnerDriver.name] = this.pendingDrivers[InnerDriver.name] || [];
|
||||
otherPendingDriversData = compact(this.pendingDrivers[InnerDriver.name].map((drv) => drv.driverData));
|
||||
this.pendingDrivers[InnerDriver.name].push(driverInstance);
|
||||
|
||||
try {
|
||||
[innerSessionId, dCaps] = (await driverInstance.createSession(
|
||||
processedW3CCapabilities as never,
|
||||
processedW3CCapabilities,
|
||||
processedW3CCapabilities,
|
||||
[...runningDriversData, ...otherPendingDriversData],
|
||||
)) as [string, DriverCaps<AppiumDriverConstraints> & {webSocketUrl?: string | boolean}];
|
||||
this.sessions[innerSessionId] = driverInstance;
|
||||
// create an IPC channel for the driver and all plugins on this session
|
||||
this.sessionIpcs[innerSessionId] = new AppiumIpc({
|
||||
maxObjSize: this.args.maxIpcDataSize,
|
||||
maxTopics: this.args.maxIpcTopics,
|
||||
log: driverInstance.log,
|
||||
});
|
||||
const extDriver = driverInstance as unknown as IpcAssignable;
|
||||
if (typeof extDriver.assignIpc === 'function') {
|
||||
// TODO remove this existence guard as a breaking change in Appium 3
|
||||
await extDriver.assignIpc(this.sessionIpcs[innerSessionId]);
|
||||
}
|
||||
} finally {
|
||||
pull(this.pendingDrivers[InnerDriver.name], driverInstance);
|
||||
}
|
||||
[innerSessionId, dCaps] = (await driverInstance.createSession(processedW3CCapabilities as never)) as [
|
||||
string,
|
||||
DriverCapsWithBidiUrl,
|
||||
];
|
||||
this.sessions[innerSessionId] = driverInstance;
|
||||
// create an IPC channel for the driver and all plugins on this session
|
||||
this.sessionIpcs[innerSessionId] = new AppiumIpc({
|
||||
maxObjSize: this.args.maxIpcDataSize,
|
||||
maxTopics: this.args.maxIpcTopics,
|
||||
log: driverInstance.log,
|
||||
});
|
||||
const extDriver = driverInstance as unknown as IpcAssignable;
|
||||
await extDriver.assignIpc(this.sessionIpcs[innerSessionId]);
|
||||
|
||||
this.attachUnexpectedShutdownHandler(driverInstance, innerSessionId);
|
||||
|
||||
@@ -423,9 +387,6 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
`Upstream driver responded with webSocketUrl ${dCaps.webSocketUrl}, will rewrite to ` +
|
||||
`${bidiUrl} for response to client`,
|
||||
);
|
||||
// @ts-ignore webSocketUrl gets sent by the client as a boolean, but then it is supposed
|
||||
// to come back from the server as a string. TODO figure out how to express this in our
|
||||
// capability constraint system
|
||||
dCaps.webSocketUrl = bidiUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -478,42 +439,15 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects `driverData` for every active session whose driver class matches `InnerDriver.name`
|
||||
* (used when creating another session of the same driver type).
|
||||
* @remarks `InnerDriver` is expected to be the driver class; only `.name` is read.
|
||||
*/
|
||||
async curSessionDataForDriver(InnerDriver: {name: string}): Promise<DriverData[]> {
|
||||
const data = compact(
|
||||
Object.values(this.sessions)
|
||||
.filter((s) => s.constructor.name === InnerDriver.name)
|
||||
.map((s) => s.driverData),
|
||||
);
|
||||
for (const datum of data) {
|
||||
if (!datum) {
|
||||
throw new Error(
|
||||
`Problem getting session data for driver type ` + `${InnerDriver.name}; does it implement 'get driverData'?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends one session: removes it from the master list immediately, then delegates to the inner
|
||||
* driver’s `deleteSession` with sibling-session metadata.
|
||||
* driver’s `deleteSession`.
|
||||
*/
|
||||
async deleteSession(sessionId: string): Promise<SessionHandlerDeleteResult> {
|
||||
let protocol: Protocol | undefined;
|
||||
try {
|
||||
let otherSessionsData: DriverData[] | undefined;
|
||||
let dstSession: ExternalDriver | undefined;
|
||||
if (this.sessions[sessionId]) {
|
||||
const curConstructorName = this.sessions[sessionId].constructor.name;
|
||||
otherSessionsData = Object.entries(this.sessions)
|
||||
.filter(([key, value]) => value.constructor.name === curConstructorName && key !== sessionId)
|
||||
.map(([, value]) => value.driverData);
|
||||
dstSession = this.sessions[sessionId];
|
||||
const dstSession: ExternalDriver | undefined = this.sessions[sessionId];
|
||||
if (dstSession) {
|
||||
protocol = dstSession.protocol;
|
||||
this.cleanupBidiSockets(sessionId);
|
||||
}
|
||||
@@ -525,7 +459,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
|
||||
return {
|
||||
protocol,
|
||||
value: await dstSession.deleteSession(sessionId, otherSessionsData),
|
||||
value: await dstSession.deleteSession(sessionId),
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -771,10 +705,7 @@ export class AppiumDriver extends DriverCore<AppiumDriverConstraints> {
|
||||
}
|
||||
// we also want to assign the IPC channel for this session to the plugins
|
||||
const extPlugin = p as unknown as IpcAssignable;
|
||||
if (typeof extPlugin.assignIpc === 'function') {
|
||||
// TODO remove this existence guard as a breaking change in Appium 4
|
||||
await extPlugin.assignIpc(this.sessionIpcs[newSessionId]);
|
||||
}
|
||||
await extPlugin.assignIpc(this.sessionIpcs[newSessionId]);
|
||||
}
|
||||
this.sessionlessPlugins = [];
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ import type {
|
||||
} from '@appium/types';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import type {AppiumDriver} from './appium';
|
||||
import {BIDI_BASE_PATH, BIDI_EVENT_NAME} from './constants';
|
||||
import {fetchInterfaces, isBroadcastIp, V4_BROADCAST_IP} from './helpers/network';
|
||||
import {capitalize} from './utils';
|
||||
import type {AppiumDriver} from './appium.js';
|
||||
import {BIDI_BASE_PATH, BIDI_EVENT_NAME} from './constants.js';
|
||||
import {fetchInterfaces, isBroadcastIp, V4_BROADCAST_IP} from './helpers/network.js';
|
||||
import {capitalize} from './utils/index.js';
|
||||
|
||||
type ExtensionPlugin = Plugin & ExtensionCore;
|
||||
type AnyDriver = ExternalDriver | AppiumDriver;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {env, util} from '@appium/support';
|
||||
import {console as supportConsole, util} from '@appium/support';
|
||||
import type {DriverOpts} from '@appium/types';
|
||||
import type {
|
||||
Args,
|
||||
@@ -7,31 +7,31 @@ import type {
|
||||
CliCommandSetupSubcommand,
|
||||
CliExtensionSubcommand,
|
||||
ParsedArgs,
|
||||
} from 'appium/types';
|
||||
} from 'appium/types/index.js';
|
||||
|
||||
import {AppiumDriver, type AppiumDriverConstraints} from '../appium';
|
||||
import {runExtensionCommand} from '../cli/extension';
|
||||
import {injectAppiumSymlinks} from '../cli/extension-command';
|
||||
import {ArgParser, getParser} from '../cli/parser';
|
||||
import {runSetupCommand} from '../cli/setup-command';
|
||||
import {SERVER_SUBCOMMAND} from '../constants';
|
||||
import {type ExtensionConfigs, loadExtensions} from '../extension';
|
||||
import {log as logger} from '../logger';
|
||||
import {init as logsinkInit} from '../logsink';
|
||||
import {AppiumDriver, type AppiumDriverConstraints} from '../appium.js';
|
||||
import {injectAppiumSymlinks} from '../cli/extension-command.js';
|
||||
import {runExtensionCommand} from '../cli/extension.js';
|
||||
import {ArgParser, getParser} from '../cli/parser.js';
|
||||
import {runSetupCommand} from '../cli/setup-command.js';
|
||||
import {SERVER_SUBCOMMAND} from '../constants.js';
|
||||
import {type ExtensionConfigs, loadExtensions} from '../extension/index.js';
|
||||
import {log as logger} from '../logger.js';
|
||||
import {init as logsinkInit} from '../logsink.js';
|
||||
import {
|
||||
isDriverCommandArgs,
|
||||
isExtensionCommandArgs,
|
||||
isPluginCommandArgs,
|
||||
isServerCommandArgs,
|
||||
isSetupCommandArgs,
|
||||
} from '../schema/cli-args-guards';
|
||||
import {getDefaultsForSchema} from '../schema/schema';
|
||||
import {defaultsDeep} from '../utils';
|
||||
import {readConfigFile} from './config-file';
|
||||
import type {InitResult, PreConfigArgs} from './init-types';
|
||||
import {determineAppiumHomeSource, preflightChecks} from './main-helpers';
|
||||
import {adjustNodePath, requireDir, showDebugInfo} from './node-helpers';
|
||||
import {getNonDefaultServerArgs, showConfig} from './startup-config';
|
||||
} from '../schema/cli-args-guards.js';
|
||||
import {getDefaultsForSchema} from '../schema/schema.js';
|
||||
import {defaultsDeep, resolveAppiumHome} from '../utils/index.js';
|
||||
import {readConfigFile} from './config-file.js';
|
||||
import type {InitResult, PreConfigArgs} from './init-types.js';
|
||||
import {determineAppiumHomeSource, preflightChecks} from './main-helpers.js';
|
||||
import {adjustNodePath, requireDir, showDebugInfo} from './node-helpers.js';
|
||||
import {getNonDefaultServerArgs, showConfig} from './startup-config.js';
|
||||
|
||||
/**
|
||||
* Parses CLI/programmatic args, loads config and extensions, and returns server-ready state or runs extension/setup flows.
|
||||
@@ -51,7 +51,7 @@ export class AppiumInitializer {
|
||||
>(args?: Args<Cmd, SubCmd>): Promise<InitResult<Cmd>> {
|
||||
this.throwInsteadOfExit = false;
|
||||
|
||||
const appiumHome = args?.appiumHome ?? (await env.resolveAppiumHome());
|
||||
const appiumHome = args?.appiumHome ?? (await resolveAppiumHome());
|
||||
const appiumHomeSourceName = determineAppiumHomeSource(args?.appiumHome);
|
||||
await requireDir(appiumHome, false, appiumHomeSourceName);
|
||||
|
||||
@@ -77,6 +77,7 @@ export class AppiumInitializer {
|
||||
}
|
||||
|
||||
if (isSetupCommandArgs(preConfigArgs)) {
|
||||
this.printValidationSummaries(driverConfig, pluginConfig);
|
||||
await runSetupCommand(preConfigArgs, driverConfig, pluginConfig);
|
||||
return {} as InitResult<Cmd>;
|
||||
}
|
||||
@@ -135,6 +136,7 @@ export class AppiumInitializer {
|
||||
|
||||
if (preConfigArgs.showConfig) {
|
||||
showConfig(getNonDefaultServerArgs(preConfigArgs as Args), configResult, defaults, serverArgs);
|
||||
this.printValidationSummaries(driverConfig, pluginConfig);
|
||||
return {} as InitResult<Cmd>;
|
||||
}
|
||||
|
||||
@@ -144,10 +146,13 @@ export class AppiumInitializer {
|
||||
pluginConfig,
|
||||
appiumHome,
|
||||
});
|
||||
this.printValidationSummaries(driverConfig, pluginConfig);
|
||||
return {} as InitResult<Cmd>;
|
||||
}
|
||||
|
||||
await logsinkInit(serverArgs);
|
||||
// Deferred from loadExtensions() so these render through the now-active (Winston-backed) logger.
|
||||
this.printValidationSummaries(driverConfig, pluginConfig);
|
||||
await this.applyLogFilters(serverArgs);
|
||||
|
||||
if (!serverArgs.noPermsCheck) {
|
||||
@@ -167,6 +172,15 @@ export class AppiumInitializer {
|
||||
} as InitResult<Cmd>;
|
||||
}
|
||||
|
||||
/** Renders each config's pending `loadExtensions()`-time validation summary via the raw logger. */
|
||||
private printValidationSummaries(
|
||||
driverConfig: ExtensionConfigs['driverConfig'],
|
||||
pluginConfig: ExtensionConfigs['pluginConfig'],
|
||||
): void {
|
||||
driverConfig.printValidationSummary(logger);
|
||||
pluginConfig.printValidationSummary(logger);
|
||||
}
|
||||
|
||||
private async applyLogFilters(serverArgs: ParsedArgs<CliCommandServer>): Promise<void> {
|
||||
if (!serverArgs.logFilters) {
|
||||
return;
|
||||
@@ -201,7 +215,10 @@ export class AppiumInitializer {
|
||||
if (isDriverCommandArgs(preConfigArgs) || isPluginCommandArgs(preConfigArgs)) {
|
||||
const cmd = isDriverCommandArgs(preConfigArgs) ? preConfigArgs.driverCommand : preConfigArgs.pluginCommand;
|
||||
if (cmd === 'install') {
|
||||
await injectAppiumSymlinks(driverConfig, pluginConfig, logger);
|
||||
// Match the same command's own console so a non-fatal symlink error never lands on
|
||||
// STDOUT under `--json` (unlike the raw npmlog logger, which isn't JSON-mode-aware).
|
||||
const symlinkLog = new supportConsole.CliConsole({jsonMode: Boolean(preConfigArgs.json)});
|
||||
await injectAppiumSymlinks(driverConfig, pluginConfig, symlinkLog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,33 @@ import net from 'node:net';
|
||||
import type {ServerOpts} from '@appium/base-driver';
|
||||
import {util} from '@appium/support';
|
||||
import type {AppiumServer} from '@appium/types';
|
||||
import type {Args, CliCommand, CliCommandServer, CliCommandSetupSubcommand, CliExtensionSubcommand} from 'appium/types';
|
||||
import type {
|
||||
Args,
|
||||
CliCommand,
|
||||
CliCommandServer,
|
||||
CliCommandSetupSubcommand,
|
||||
CliExtensionSubcommand,
|
||||
} from 'appium/types/index.js';
|
||||
|
||||
import {getActiveDrivers, getActivePlugins} from '../extension';
|
||||
import {log as logger} from '../logger';
|
||||
import registerNode from './grid-v3-register';
|
||||
import type {InitResult, ServerInitData} from './init-types';
|
||||
import {getActiveDrivers, getActivePlugins} from '../extension/index.js';
|
||||
import {log as logger} from '../logger.js';
|
||||
import type {InitResult, ServerInitData} from './init-types.js';
|
||||
import {
|
||||
buildServerOpts,
|
||||
createAppiumServer,
|
||||
determineAppiumHomeSource,
|
||||
logServerAddress,
|
||||
logStartupInfo,
|
||||
} from './main-helpers';
|
||||
} from './main-helpers.js';
|
||||
|
||||
const MAX_SERVER_PROCESS_LISTENERS = 100;
|
||||
|
||||
/**
|
||||
* Starts the Appium HTTP server after {@link AppiumInitializer.init}: loads drivers/plugins, binds, grid register, signals.
|
||||
* Starts the Appium HTTP server after {@link AppiumInitializer.init}: loads drivers/plugins, binds, signals.
|
||||
*/
|
||||
export class AppiumMainRunner {
|
||||
/**
|
||||
* For server init: builds listeners, registers with Grid 3 if configured, and returns the server.
|
||||
* For server init: builds listeners and returns the server.
|
||||
* For non-server commands, `initResult` is empty and this resolves to `undefined`.
|
||||
*
|
||||
* @param initResult - Output of {@link AppiumInitializer.init}
|
||||
@@ -55,6 +60,8 @@ export class AppiumMainRunner {
|
||||
await logStartupInfo(parsedArgs);
|
||||
|
||||
appiumDriver.configureGlobalFeatures();
|
||||
// the rules are enforced by the shared app download helper of every driver
|
||||
appiumDriver.helpers.setAppUrlRules(parsedArgs.appUrlRules);
|
||||
|
||||
const appiumHomeSourceName = determineAppiumHomeSource(args?.appiumHome);
|
||||
logger.debug(`The ${appiumHomeSourceName}: ${appiumHome}`);
|
||||
@@ -71,10 +78,9 @@ export class AppiumMainRunner {
|
||||
return undefined as Cmd extends CliCommandServer ? AppiumServer : void;
|
||||
}
|
||||
|
||||
this.warnIfCorsEnabled(parsedArgs);
|
||||
this.warnIfCorsEnabled(serverOpts);
|
||||
appiumDriver.server = server;
|
||||
|
||||
await this.registerGridOrClose(server, parsedArgs, normalizedBasePath);
|
||||
this.attachSignalHandlers(appiumDriver, server);
|
||||
this.logListeningUrl(server, parsedArgs, normalizedBasePath);
|
||||
|
||||
@@ -104,8 +110,8 @@ export class AppiumMainRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private warnIfCorsEnabled(parsedArgs: ServerInitData['parsedArgs']): void {
|
||||
if (parsedArgs.allowCors) {
|
||||
private warnIfCorsEnabled(serverOpts: ServerOpts): void {
|
||||
if (serverOpts.allowCors) {
|
||||
logger.warn(
|
||||
'You have enabled CORS requests from any host. Be careful not ' +
|
||||
'to visit sites which could maliciously try to start Appium ' +
|
||||
@@ -114,21 +120,6 @@ export class AppiumMainRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private async registerGridOrClose(
|
||||
server: AppiumServer,
|
||||
parsedArgs: ServerInitData['parsedArgs'],
|
||||
normalizedBasePath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (parsedArgs.nodeconfig) {
|
||||
await registerNode(parsedArgs.nodeconfig, parsedArgs.address, parsedArgs.port, normalizedBasePath);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
await server.close();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private attachSignalHandlers(appiumDriver: ServerInitData['appiumDriver'], server: AppiumServer): void {
|
||||
process.setMaxListeners(MAX_SERVER_PROCESS_LISTENERS);
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {util} from '@appium/support';
|
||||
import type {AppiumConfig, NormalizedAppiumConfig} from '@appium/types';
|
||||
import type {IOutputError} from '@sidvind/better-ajv-errors';
|
||||
import type {ErrorObject, SchemaObject} from 'ajv';
|
||||
import {lilconfig, type LilconfigResult, type LoaderSync} from 'lilconfig';
|
||||
import * as yaml from 'yaml';
|
||||
|
||||
import {formatErrors} from '../schema/format-errors';
|
||||
import {getSchema, validate} from '../schema/schema';
|
||||
import {camelCase, getPath, mapKeys, mapValues} from '../utils';
|
||||
import type {ErrorObject, SchemaObject} from '../schema/ajv.js';
|
||||
import {formatErrors} from '../schema/format-errors.js';
|
||||
import {getSchema, validate} from '../schema/schema.js';
|
||||
import {camelCase, getPath, mapKeys, mapValues} from '../utils/index.js';
|
||||
|
||||
/**
|
||||
* A cache of the raw config file (a JSON string) at a filepath.
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import {fs} from '@appium/support';
|
||||
import type {StringRecord} from '@appium/types';
|
||||
import axios from 'axios';
|
||||
|
||||
import {log as logger} from '../logger';
|
||||
|
||||
/**
|
||||
* Selenium **Grid 3** (legacy hub) node integration.
|
||||
*
|
||||
* Registers this Appium process as a node with a Selenium Grid 3 hub using the classic
|
||||
* `/grid/register` endpoint and `/grid/api/proxy` for re-registration checks.
|
||||
* This does **not** apply to Selenium Grid 4 (which uses a different topology and APIs).
|
||||
*
|
||||
* @see https://www.selenium.dev/documentation/legacy/grid_3/
|
||||
*/
|
||||
|
||||
/** REST paths exposed by a Selenium Grid 3 hub. */
|
||||
const GRID_V3_REGISTER_PATH = '/grid/register';
|
||||
const GRID_V3_PROXY_API_PATH = '/grid/api/proxy';
|
||||
|
||||
interface Grid3HubConfiguration {
|
||||
hubProtocol?: string;
|
||||
hubHost?: string;
|
||||
hubPort?: number;
|
||||
url?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
register?: boolean;
|
||||
registerCycle?: number;
|
||||
}
|
||||
|
||||
interface Grid3NodeConfig extends StringRecord {
|
||||
configuration?: Grid3HubConfiguration;
|
||||
capabilities?: unknown;
|
||||
}
|
||||
|
||||
interface Grid3ProxyApiResponse {
|
||||
success: boolean;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export default async function registerNode(data: string, addr: string, port: number, basePath: string): Promise<void>;
|
||||
export default async function registerNode(
|
||||
data: Grid3NodeConfig,
|
||||
addr?: string,
|
||||
port?: number,
|
||||
basePath?: string,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Registers this server as a node with a **Selenium Grid 3** hub.
|
||||
*
|
||||
* When `data` is a path to a JSON config file, `addr`, `port`, and `basePath` are required (they define
|
||||
* how the hub reaches this Appium server). When `data` is an inline config object, those parameters are
|
||||
* optional and only used to fill missing node URL fields in the config.
|
||||
*
|
||||
* @deprecated Selenium Grid 3 registration is legacy and slated for removal.
|
||||
* Use Selenium Grid 4 integration (node relay) instead.
|
||||
*
|
||||
* @param data - Path to a JSON file or an inline parsed config object
|
||||
* @param addr - Bind to this address
|
||||
* @param port - Bind to this port
|
||||
* @param basePath - Base path for the Appium server (used in the node URL sent to the hub; may be `''`)
|
||||
*/
|
||||
export default async function registerNode(
|
||||
data: string | Grid3NodeConfig,
|
||||
addr?: string,
|
||||
port?: number,
|
||||
basePath?: string,
|
||||
): Promise<void> {
|
||||
if (typeof data === 'string') {
|
||||
if (addr === undefined || port === undefined || basePath === undefined) {
|
||||
throw logger.errorWithException(
|
||||
'When the first argument is a Selenium Grid 3 node config file path, address, port, and basePath ' +
|
||||
'are required (e.g. match your Appium `--address`, `--port`, and base path).',
|
||||
);
|
||||
}
|
||||
if (typeof port !== 'number' || !Number.isFinite(port)) {
|
||||
throw logger.errorWithException('When registering from a node config file path, port must be a finite number.');
|
||||
}
|
||||
}
|
||||
|
||||
let configHolder: Grid3NodeConfig;
|
||||
if (typeof data === 'string') {
|
||||
const configFilePath = data;
|
||||
let fileContent: string;
|
||||
try {
|
||||
fileContent = await fs.readFile(data, 'utf-8');
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Unable to load Selenium Grid 3 node configuration file ${configFilePath} to ` +
|
||||
`register with the hub: ${(err as Error).message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
configHolder = JSON.parse(fileContent) as Grid3NodeConfig;
|
||||
} catch (err) {
|
||||
throw logger.errorWithException(
|
||||
`Syntax error in Selenium Grid 3 node configuration file ${configFilePath}: ` + (err as Error).message,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
configHolder = data;
|
||||
}
|
||||
|
||||
postRequest(configHolder, addr, port, basePath);
|
||||
}
|
||||
|
||||
/** Base URL for the Selenium Grid 3 hub (protocol + host + port). */
|
||||
function hubUri(config: Grid3HubConfiguration): string {
|
||||
const protocol = config.hubProtocol || 'http';
|
||||
return `${protocol}://${config.hubHost}:${config.hubPort}`;
|
||||
}
|
||||
|
||||
/** POST registration payload to the Selenium Grid 3 hub. */
|
||||
async function registerToGrid(
|
||||
postOptions: {url: string; method: string; data: Grid3NodeConfig},
|
||||
configHolder: Grid3NodeConfig,
|
||||
): Promise<void> {
|
||||
const hubCfg = configHolder.configuration;
|
||||
if (!hubCfg) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const {status} = await axios(postOptions);
|
||||
if (status !== 200) {
|
||||
throw new Error(`Request failed with code ${status}`);
|
||||
}
|
||||
logger.debug(`Appium successfully registered with the Selenium Grid 3 hub at ` + hubUri(hubCfg));
|
||||
} catch (err) {
|
||||
logger.error(`An attempt to register with the Selenium Grid 3 hub was unsuccessful: ` + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function postRequest(configHolder: Grid3NodeConfig, addr?: string, port?: number, basePath?: string): void {
|
||||
// Move Selenium Grid 3 (flat) configuration properties into `configuration`
|
||||
if (configHolder != null && typeof configHolder === 'object' && !Object.hasOwn(configHolder, 'configuration')) {
|
||||
const configuration: StringRecord = {};
|
||||
const holder = configHolder as StringRecord;
|
||||
for (const property of Object.keys(holder)) {
|
||||
if (property !== 'capabilities') {
|
||||
configuration[property] = holder[property];
|
||||
delete holder[property];
|
||||
}
|
||||
}
|
||||
holder.configuration = configuration as Grid3HubConfiguration;
|
||||
}
|
||||
|
||||
const cfg = configHolder.configuration;
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the node config does not have the appium/webdriver url, host, and port,
|
||||
// automatically add it based on how appium was initialized
|
||||
// otherwise, we will take whatever the user setup
|
||||
// because we will always set localhost/127.0.0.1. this won't work if your
|
||||
// node and hub aren't in the same place
|
||||
if (!cfg.url || !cfg.host || !cfg.port) {
|
||||
cfg.url = `http://${addr}:${port}${basePath}`;
|
||||
cfg.host = addr;
|
||||
cfg.port = port;
|
||||
}
|
||||
// if the node config does not have id automatically add it
|
||||
if (!cfg.id) {
|
||||
cfg.id = `http://${cfg.host}:${cfg.port}`;
|
||||
}
|
||||
|
||||
// the post options
|
||||
const regRequest = {
|
||||
url: `${hubUri(cfg)}${GRID_V3_REGISTER_PATH}`,
|
||||
method: 'POST',
|
||||
data: configHolder,
|
||||
};
|
||||
|
||||
if (cfg.register !== true) {
|
||||
logger.debug(`No Selenium Grid 3 hub registration sent (${cfg.register} = false)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const registerCycleInterval = cfg.registerCycle;
|
||||
if (registerCycleInterval === undefined || isNaN(registerCycleInterval) || registerCycleInterval <= 0) {
|
||||
logger.warn(
|
||||
`'registerCycle' is not a valid positive number. ` +
|
||||
`No registration request will be sent to the Selenium Grid 3 hub.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// initiate a new Thread
|
||||
let first = true;
|
||||
logger.debug(
|
||||
`Starting auto-register thread for Selenium Grid 3. ` + `Will try to register every ${registerCycleInterval} ms.`,
|
||||
);
|
||||
setInterval(async function registerRetry() {
|
||||
if (first) {
|
||||
first = false;
|
||||
await registerToGrid(regRequest, configHolder);
|
||||
} else if (!(await isAlreadyRegistered(configHolder))) {
|
||||
// make the http POST to the Selenium Grid 3 hub for registration
|
||||
await registerToGrid(regRequest, configHolder);
|
||||
}
|
||||
}, registerCycleInterval);
|
||||
}
|
||||
|
||||
/** Query the Selenium Grid 3 hub to see if this node id is already registered. */
|
||||
async function isAlreadyRegistered(configHolder: Grid3NodeConfig): Promise<boolean | undefined> {
|
||||
// check if node is already registered
|
||||
const hubCfg = configHolder.configuration;
|
||||
if (!hubCfg?.id) {
|
||||
return;
|
||||
}
|
||||
const id = hubCfg.id;
|
||||
try {
|
||||
const {data, status} = await axios<Grid3ProxyApiResponse>({
|
||||
url: `${hubUri(hubCfg)}${GRID_V3_PROXY_API_PATH}`,
|
||||
params: {id},
|
||||
timeout: 10000,
|
||||
});
|
||||
if (status !== 200) {
|
||||
throw new Error(`Request failed with code ${status}`);
|
||||
}
|
||||
if (!data.success) {
|
||||
// if register fail, print the debug msg
|
||||
logger.debug(`Selenium Grid 3 hub registration check: ${data.msg}`);
|
||||
}
|
||||
return data.success;
|
||||
} catch (err) {
|
||||
logger.debug(`Selenium Grid 3 hub down or not responding: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import type {
|
||||
CliCommandSetupSubcommand,
|
||||
CliExtensionSubcommand,
|
||||
ParsedArgs,
|
||||
} from 'appium/types';
|
||||
} from 'appium/types/index.js';
|
||||
|
||||
import type {AppiumDriver} from '../appium';
|
||||
import type {ExtensionConfigs} from '../extension';
|
||||
import type {AppiumDriver} from '../appium.js';
|
||||
import type {ExtensionConfigs} from '../extension/index.js';
|
||||
|
||||
/** Empty object returned when `init` completes for non-server CLI flows (extension/setup). */
|
||||
export type ExtCommandInitResult = Record<string, never>;
|
||||
|
||||
@@ -9,18 +9,18 @@ import {
|
||||
} from '@appium/base-driver';
|
||||
import {console as supportConsole, util} from '@appium/support';
|
||||
import type {AppiumServer, Driver, MethodMap, UpdateServerCallback} from '@appium/types';
|
||||
import type {Args, CliCommandServer, ParsedArgs} from 'appium/types';
|
||||
import type {Args, CliCommandServer, ParsedArgs} from 'appium/types/index.js';
|
||||
import {WebSocketServer} from 'ws';
|
||||
|
||||
import type {AppiumDriver} from '../appium';
|
||||
import {BIDI_BASE_PATH, LONG_STACKTRACE_LIMIT} from '../constants';
|
||||
import type {DriverNameMap, PluginNameMap} from '../extension';
|
||||
import {APPIUM_VER, getBuildInfo, getGitRev, updateBuildInfo} from '../helpers/build';
|
||||
import {fetchInterfaces, isBroadcastIp, V4_BROADCAST_IP} from '../helpers/network';
|
||||
import {log as logger} from '../logger';
|
||||
import {validate as validateSchema} from '../schema/schema';
|
||||
import {checkNodeOk, requireDir} from './node-helpers';
|
||||
import {getNonDefaultServerArgs} from './startup-config';
|
||||
import type {AppiumDriver} from '../appium.js';
|
||||
import {BIDI_BASE_PATH, CORS_FEATURE, LONG_STACKTRACE_LIMIT} from '../constants.js';
|
||||
import type {DriverNameMap, PluginNameMap} from '../extension/index.js';
|
||||
import {APPIUM_VER, getBuildInfo, getGitRev, updateBuildInfo} from '../helpers/build.js';
|
||||
import {fetchInterfaces, isBroadcastIp, V4_BROADCAST_IP} from '../helpers/network.js';
|
||||
import {log as logger} from '../logger.js';
|
||||
import {validate as validateSchema} from '../schema/schema.js';
|
||||
import {checkNodeOk, requireDir} from './node-helpers.js';
|
||||
import {getNonDefaultServerArgs} from './startup-config.js';
|
||||
|
||||
const isStdoutTTY = process.stdout.isTTY;
|
||||
|
||||
@@ -173,7 +173,7 @@ export function buildServerOpts(
|
||||
routeConfiguringFunction,
|
||||
port: parsedArgs.port,
|
||||
hostname: parsedArgs.address,
|
||||
allowCors: parsedArgs.allowCors,
|
||||
allowCors: appiumDriver.isFeatureEnabled(CORS_FEATURE),
|
||||
basePath: parsedArgs.basePath,
|
||||
serverUpdaters: getServerUpdaters(driverClasses, pluginClasses),
|
||||
extraMethodMap: getExtraMethodMap(driverClasses, pluginClasses),
|
||||
@@ -206,9 +206,18 @@ export async function createAppiumServer(
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server args whose values describe the server's internal network and must not leak to clients
|
||||
* via server logs
|
||||
*/
|
||||
const SENSITIVE_ARGS: ReadonlySet<keyof Args> = new Set(['appUrlRules']);
|
||||
|
||||
function logNonDefaultArgsWarning(args: Args): void {
|
||||
const shownArgs = Object.fromEntries(
|
||||
Object.entries(args).map(([name, value]) => [name, SENSITIVE_ARGS.has(name as keyof Args) ? '<redacted>' : value]),
|
||||
);
|
||||
logger.info('Non-default server args:');
|
||||
inspect(args);
|
||||
inspect(shownArgs);
|
||||
}
|
||||
|
||||
function logDefaultCapabilitiesWarning(caps: ParsedArgs<CliCommandServer>['defaultCapabilities']): void {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import {Module} from 'node:module';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import {fs, npm, system} from '@appium/support';
|
||||
import {fs, system} from '@appium/support';
|
||||
import * as semver from 'semver';
|
||||
|
||||
import {getBuildInfo, updateBuildInfo} from '../helpers/build';
|
||||
import {appiumPackageRoot, npmPackage} from '../utils';
|
||||
import {getBuildInfo, updateBuildInfo} from '../helpers/build.js';
|
||||
import {appiumPackageRoot, npm, npmPackage} from '../utils/index.js';
|
||||
|
||||
const MIN_NODE_VERSION = (npmPackage.engines as Record<string, string>).node;
|
||||
|
||||
interface NodeModuleWithInitPaths {
|
||||
Module: {_initPaths(): void};
|
||||
interface ModuleWithInitPaths {
|
||||
_initPaths(): void;
|
||||
}
|
||||
|
||||
interface DebugInfoInput {
|
||||
@@ -31,15 +32,19 @@ export function checkNodeOk(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts NODE_PATH so CJS drivers/plugins can load peer deps. Does not work with ESM.
|
||||
* Adjusts NODE_PATH so CJS drivers/plugins can load peer deps. Does not help ESM
|
||||
* drivers/plugins, since real ESM `import` resolution never consults NODE_PATH.
|
||||
*/
|
||||
export function adjustNodePath(): void {
|
||||
const appiumModuleSearchRoot = path.dirname(appiumPackageRoot);
|
||||
|
||||
const refreshRequirePaths = (): boolean => {
|
||||
try {
|
||||
// Private API; see https://gist.github.com/branneman/8048520#7-the-hack
|
||||
(require('node:module') as NodeModuleWithInitPaths).Module._initPaths();
|
||||
// `Module._initPaths()` re-reads `process.env.NODE_PATH` into Node's global CJS module
|
||||
// search paths; a CJS driver/plugin's own later `require()` calls (even though appium
|
||||
// itself is ESM now) see the update, since this affects the whole process, not just the
|
||||
// caller. Private API; see https://gist.github.com/branneman/8048520#7-the-hack
|
||||
(Module as unknown as ModuleWithInitPaths)._initPaths();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable no-console */
|
||||
import {util} from '@appium/support';
|
||||
import type {Args} from 'appium/types';
|
||||
import type {Args} from 'appium/types/index.js';
|
||||
|
||||
import {getAllArgSpecs, getDefaultsForSchema} from '../schema/schema';
|
||||
import {difference, getPath, pickBy, setPath} from '../utils';
|
||||
import type {ReadConfigFileResult} from './config-file';
|
||||
import {getAllArgSpecs, getDefaultsForSchema} from '../schema/schema.js';
|
||||
import {difference, getPath, pickBy, setPath} from '../utils/index.js';
|
||||
import type {ReadConfigFileResult} from './config-file.js';
|
||||
|
||||
interface FlattenedArg {
|
||||
value: unknown;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {util} from '@appium/support';
|
||||
import type {ExtensionType} from '@appium/types';
|
||||
import type {CliExtensionSubcommand} from 'appium/types';
|
||||
import type {ArgumentOptions} from 'argparse';
|
||||
import type {CliExtensionSubcommand} from 'appium/types/index.js';
|
||||
|
||||
import {
|
||||
DRIVER_TYPE,
|
||||
@@ -12,14 +11,13 @@ import {
|
||||
EXT_SUBCOMMAND_UNINSTALL,
|
||||
EXT_SUBCOMMAND_UPDATE,
|
||||
PLUGIN_TYPE,
|
||||
} from '../constants';
|
||||
import {INSTALL_TYPES} from '../extension/extension-config';
|
||||
import {toParserArgs} from '../schema/cli-args';
|
||||
} from '../constants.js';
|
||||
import {INSTALL_TYPES} from '../extension/manifest/index.js';
|
||||
import {toParserArgs} from '../schema/cli-args.js';
|
||||
import type {ArgumentDefinitions} from '../schema/cli-args.js';
|
||||
const DRIVER_EXAMPLE = 'xcuitest';
|
||||
const PLUGIN_EXAMPLE = 'images';
|
||||
|
||||
export type ArgumentDefinitions = Map<[name: string] | [name: string, alias: string], ArgumentOptions>;
|
||||
|
||||
/**
|
||||
* This is necessary because we pass the array into `argparse`. `argparse` is bad and mutates things. We don't want that.
|
||||
* Bad `argparse`! Bad!
|
||||
@@ -164,8 +162,7 @@ function makeUninstallArgs(type: ExtensionType): ArgumentDefinitions {
|
||||
{
|
||||
type: 'str',
|
||||
help:
|
||||
`Short name or npm package name of the ${type} to uninstall, for example: ` +
|
||||
(type === DRIVER_TYPE ? DRIVER_EXAMPLE : PLUGIN_EXAMPLE),
|
||||
`Name of the ${type} to uninstall, for example: ` + (type === DRIVER_TYPE ? DRIVER_EXAMPLE : PLUGIN_EXAMPLE),
|
||||
},
|
||||
],
|
||||
]);
|
||||
@@ -182,7 +179,7 @@ function makeDoctorArgs(type: ExtensionType): ArgumentDefinitions {
|
||||
{
|
||||
type: 'str',
|
||||
help:
|
||||
`Short name or npm package name of the ${type} to run doctor checks for, for example: ` +
|
||||
`Name of the ${type} to run doctor checks for, for example: ` +
|
||||
(type === DRIVER_TYPE ? DRIVER_EXAMPLE : PLUGIN_EXAMPLE),
|
||||
},
|
||||
],
|
||||
@@ -200,7 +197,7 @@ function makeUpdateArgs(type: ExtensionType): ArgumentDefinitions {
|
||||
{
|
||||
type: 'str',
|
||||
help:
|
||||
`Short name or npm package name of the ${type} to update, or "installed" to update all installed ${type}s. ` +
|
||||
`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),
|
||||
@@ -229,7 +226,7 @@ function makeRunArgs(type: ExtensionType): ArgumentDefinitions {
|
||||
{
|
||||
type: 'str',
|
||||
help:
|
||||
`Short name or npm package name of the ${type} to run a script from, for example: ` +
|
||||
`Name of the ${type} to run a script from, for example: ` +
|
||||
(type === DRIVER_TYPE ? DRIVER_EXAMPLE : PLUGIN_EXAMPLE),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {console, util} from '@appium/support';
|
||||
import type {ExtMetadata, ExtRecord, InstallType} from 'appium/types';
|
||||
import type {ExtMetadata, ExtRecord, InstallType} from 'appium/types/index.js';
|
||||
|
||||
import {KNOWN_DRIVERS} from '../constants';
|
||||
import {ExtensionCliCommand} from './extension-command';
|
||||
import {KNOWN_DRIVERS} from '../constants.js';
|
||||
import {ExtensionCliCommand} from './extension-command.js';
|
||||
import type {
|
||||
ExtensionArgs,
|
||||
ExtensionCommandOptions,
|
||||
ExtensionUpdateResult,
|
||||
PostInstallText,
|
||||
RunOutput,
|
||||
} from './extension-command';
|
||||
} from './extension-command.js';
|
||||
const REQ_DRIVER_FIELDS = ['driverName', 'automationName', 'platformNames', 'mainClass'];
|
||||
type DriverInstallOpts = {driver: string; installType: InstallType; packageName?: string};
|
||||
type DriverUninstallOpts = {driver: string};
|
||||
|
||||
@@ -3,8 +3,8 @@ import path from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
import {inspect} from 'node:util';
|
||||
|
||||
import {console, env, fs, npm, system, util} from '@appium/support';
|
||||
import type {AppiumLogger, ExtensionType, IDoctorCheck} from '@appium/types';
|
||||
import {console, fs, system, util} from '@appium/support';
|
||||
import type {ExtensionType, IDoctorCheck} from '@appium/types';
|
||||
import type {
|
||||
ExtInstallReceipt as AppiumExtInstallReceipt,
|
||||
ExtManifest as AppiumExtManifest,
|
||||
@@ -12,23 +12,23 @@ import type {
|
||||
ExtPackageJson as AppiumExtPackageJson,
|
||||
ExtRecord as AppiumExtRecord,
|
||||
InstallType,
|
||||
} from 'appium/types';
|
||||
} from 'appium/types/index.js';
|
||||
import {asyncfilter, asyncmap} from 'asyncbox';
|
||||
import * as semver from 'semver';
|
||||
import {SubProcess} from 'teen_process';
|
||||
import type {PackageJson} from 'type-fest';
|
||||
|
||||
import {Doctor, EXIT_CODE as DOCTOR_EXIT_CODE} from '../doctor/doctor';
|
||||
import type {ExtensionConfig as BaseExtensionConfig} from '../extension/extension-config';
|
||||
import {Doctor, EXIT_CODE as DOCTOR_EXIT_CODE} from '../doctor/doctor.js';
|
||||
import type {ExtensionConfig as BaseExtensionConfig} from '../extension/extension-config.js';
|
||||
import {
|
||||
INSTALL_TYPE_DEV,
|
||||
INSTALL_TYPE_GIT,
|
||||
INSTALL_TYPE_GITHUB,
|
||||
INSTALL_TYPE_LOCAL,
|
||||
INSTALL_TYPE_NPM,
|
||||
} from '../extension/extension-config';
|
||||
import {appiumPackageRoot, compact, npmPackage, packageDidChange} from '../utils';
|
||||
import {RingBuffer, spinWith} from './utils';
|
||||
} from '../extension/manifest/index.js';
|
||||
import {appiumPackageRoot, compact, hasAppiumDependency, npm, npmPackage, packageDidChange} from '../utils/index.js';
|
||||
import {RingBuffer, spinWith} from './utils.js';
|
||||
|
||||
const UPDATE_ALL = 'installed';
|
||||
const MAX_CONCURRENT_REPO_FETCHES = 5;
|
||||
@@ -161,6 +161,9 @@ type GetInstallationReceiptOpts<ExtType extends ExtensionType = ExtensionType> =
|
||||
|
||||
type InstalledExtensionLike = {installType?: InstallType; installPath?: string};
|
||||
|
||||
/** Minimal logger shape accepted where callers must control whether output can hit `STDOUT`. */
|
||||
type InfoSink = {info(message?: string, ...args: any[]): void};
|
||||
|
||||
class NotUpdatableError extends Error {}
|
||||
|
||||
class NoUpdatesAvailableError extends Error {}
|
||||
@@ -193,6 +196,15 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
this.isJsonOutput = Boolean(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the config's pending manifest-validation summary (from the `loadExtensions()` call at
|
||||
* CLI startup) through this command's own JSON-mode-aware console, so it's suppressed under
|
||||
* `--json` just like any other non-JSON output.
|
||||
*/
|
||||
printPendingValidationSummary(): void {
|
||||
this.config.printValidationSummary(this.log);
|
||||
}
|
||||
|
||||
/**
|
||||
* `driver` or `plugin`, depending on the `ExtensionConfig`.
|
||||
*/
|
||||
@@ -256,20 +268,6 @@ 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
|
||||
*/
|
||||
@@ -413,7 +411,7 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
await this.config.addExtension(extName, extManifest as any);
|
||||
|
||||
// update the hash if we've changed the local `package.json`
|
||||
if (await env.hasAppiumDependency(this.config.appiumHome)) {
|
||||
if (await hasAppiumDependency(this.config.appiumHome)) {
|
||||
await packageDidChange(this.config.appiumHome);
|
||||
}
|
||||
|
||||
@@ -432,8 +430,7 @@ 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: rawInstallSpec}: UninstallOpts): Promise<Record<string, any>> {
|
||||
const installSpec = this._resolveExtensionName(rawInstallSpec);
|
||||
protected async _uninstall({installSpec}: UninstallOpts): Promise<Record<string, any>> {
|
||||
if (!this.config.isInstalled(installSpec)) {
|
||||
throw this._createFatalError(`Can't uninstall ${this.type} '${installSpec}'; it is not installed`);
|
||||
}
|
||||
@@ -454,9 +451,8 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
/**
|
||||
* Attempt to update one or more drivers using NPM
|
||||
*/
|
||||
protected async _update({installSpec: rawInstallSpec, unsafe}: ExtensionUpdateOpts): Promise<ExtensionUpdateResult> {
|
||||
const shouldUpdateAll = rawInstallSpec === UPDATE_ALL;
|
||||
const installSpec = shouldUpdateAll ? rawInstallSpec : this._resolveExtensionName(rawInstallSpec);
|
||||
protected async _update({installSpec, unsafe}: ExtensionUpdateOpts): Promise<ExtensionUpdateResult> {
|
||||
const shouldUpdateAll = installSpec === UPDATE_ALL;
|
||||
// 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`);
|
||||
@@ -575,8 +571,7 @@ 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: rawInstallSpec}: DoctorOptions): Promise<number> {
|
||||
const installSpec = this._resolveExtensionName(rawInstallSpec);
|
||||
protected async _doctor({installSpec}: DoctorOptions): Promise<number> {
|
||||
if (!this.config.isInstalled(installSpec)) {
|
||||
throw this._createFatalError(`The ${this.type} "${installSpec}" is not installed`);
|
||||
}
|
||||
@@ -661,12 +656,11 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
* not found within `scripts` object.
|
||||
*/
|
||||
protected async _run({
|
||||
installSpec: rawInstallSpec,
|
||||
installSpec,
|
||||
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`);
|
||||
}
|
||||
@@ -808,7 +802,12 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
const {pkg, installPath} = await spinWith(
|
||||
this.isJsonOutput,
|
||||
installMsg,
|
||||
async () => await npm.installPackage(appiumHome, installStr, {pkgName, installType}),
|
||||
async () =>
|
||||
await npm.installPackage(appiumHome, installStr, {
|
||||
pkgName,
|
||||
installType,
|
||||
hasAppiumDependency: await hasAppiumDependency(appiumHome),
|
||||
}),
|
||||
);
|
||||
|
||||
const validatedPkg = await spinWith(this.isJsonOutput, validateMsg, async () =>
|
||||
@@ -1133,7 +1132,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 ` +
|
||||
@@ -1166,13 +1169,14 @@ export abstract class ExtensionCliCommand<ExtType extends ExtensionType = Extens
|
||||
*
|
||||
* @param driverConfig - active driver extension config
|
||||
* @param pluginConfig - active plugin extension config
|
||||
* @param logger - logger instance used for non-fatal symlink errors
|
||||
* @param logger - sink for non-fatal symlink errors; pass a JSON-mode-aware `CliConsole` so this
|
||||
* never writes to `STDOUT` under `--json`
|
||||
* @returns resolves when symlink injection has completed for all extensions
|
||||
*/
|
||||
export async function injectAppiumSymlinks(
|
||||
driverConfig: ExtensionConfig<any>,
|
||||
pluginConfig: ExtensionConfig<any>,
|
||||
logger: AppiumLogger,
|
||||
logger: InfoSink,
|
||||
): Promise<void> {
|
||||
const isNpmInstalledExtension = (
|
||||
details: InstalledExtensionLike,
|
||||
@@ -1239,7 +1243,7 @@ async function getRemoteExtensionVersionReq(pkgName: string, pkgVer?: string): P
|
||||
*
|
||||
* @param dstFolder The destination folder where the symlink should be created
|
||||
*/
|
||||
async function injectAppiumSymlink(dstFolder: string, logger: AppiumLogger): Promise<void> {
|
||||
async function injectAppiumSymlink(dstFolder: string, logger: InfoSink): Promise<void> {
|
||||
try {
|
||||
const symlinkPath = path.join(dstFolder, path.basename(appiumPackageRoot));
|
||||
if ((await fs.exists(dstFolder)) && !(await fs.exists(symlinkPath))) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/* eslint-disable no-console */
|
||||
import type {Class, DriverType, ExtensionType, PluginType} from '@appium/types';
|
||||
import type {Args, CliExtensionCommand, CliExtensionSubcommand} from 'appium/types';
|
||||
import type {Args, CliExtensionCommand, CliExtensionSubcommand} from 'appium/types/index.js';
|
||||
|
||||
import {DRIVER_TYPE, PLUGIN_TYPE} from '../constants';
|
||||
import type {ExtensionConfig} from '../extension/extension-config';
|
||||
import {isExtensionCommandArgs} from '../schema/cli-args-guards';
|
||||
import DriverCliCommand from './driver-command';
|
||||
import PluginCliCommand from './plugin-command';
|
||||
import {errAndQuit, JSON_SPACES} from './utils';
|
||||
import {DRIVER_TYPE, PLUGIN_TYPE} from '../constants.js';
|
||||
import type {ExtensionConfig} from '../extension/extension-config.js';
|
||||
import {reloadManifest} from '../extension/index.js';
|
||||
import {isExtensionCommandArgs} from '../schema/cli-args-guards.js';
|
||||
import {withManifestLock} from '../utils/index.js';
|
||||
import DriverCliCommand from './driver-command.js';
|
||||
import PluginCliCommand from './plugin-command.js';
|
||||
import {errAndQuit, JSON_SPACES} from './utils.js';
|
||||
|
||||
export const commandClasses = Object.freeze({
|
||||
[DRIVER_TYPE]: DriverCliCommand,
|
||||
@@ -30,9 +32,6 @@ export async function runExtensionCommand<Cmd extends CliExtensionCommand, SubCm
|
||||
args: Args<Cmd, SubCmd>,
|
||||
config: ExtensionConfig<Cmd>,
|
||||
) {
|
||||
// TODO driver config file should be locked while any of these commands are
|
||||
// running to prevent weird situations
|
||||
let jsonResult: Record<string, unknown> = {};
|
||||
const {extensionType: type} = config; // NOTE this is the same as `args.subcommand`
|
||||
if (!isExtensionCommandArgs(args)) {
|
||||
throw new TypeError(`Cannot call ${type} command without a subcommand like 'install'`);
|
||||
@@ -43,22 +42,35 @@ export async function runExtensionCommand<Cmd extends CliExtensionCommand, SubCm
|
||||
if (suppressOutput) {
|
||||
json = true;
|
||||
}
|
||||
const CommandClass = commandClasses[type] as ExtCommand<Cmd>;
|
||||
const cmd = new CommandClass({config, json} as any);
|
||||
try {
|
||||
jsonResult = (await cmd.execute(args)) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
// in the suppress output case, we are calling this function internally and should
|
||||
// just throw instead of printing an error and ending the process
|
||||
if (suppressOutput) {
|
||||
throw err;
|
||||
|
||||
// Serialize this against any other `driver`/`plugin` CLI command running (in this or another
|
||||
// process) against the same `APPIUM_HOME`, so concurrent commands can't race to read, mutate,
|
||||
// and write the same extension manifest out from under each other.
|
||||
return withManifestLock<Record<string, unknown>>(config.appiumHome, async () => {
|
||||
// Refresh from disk while holding the lock, in case another process wrote to the manifest
|
||||
// between this process's startup read and now, and revalidate so derived state (installed
|
||||
// extensions, pending validation summary, duplicate-automationName tracking) is current too.
|
||||
await reloadManifest(config.manifest);
|
||||
|
||||
let jsonResult: Record<string, unknown> = {};
|
||||
const CommandClass = commandClasses[type] as ExtCommand<Cmd>;
|
||||
const cmd = new CommandClass({config, json} as any);
|
||||
cmd.printPendingValidationSummary();
|
||||
try {
|
||||
jsonResult = (await cmd.execute(args)) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
// in the suppress output case, we are calling this function internally and should
|
||||
// just throw instead of printing an error and ending the process
|
||||
if (suppressOutput) {
|
||||
throw err;
|
||||
}
|
||||
errAndQuit(json, err);
|
||||
}
|
||||
errAndQuit(json, err);
|
||||
}
|
||||
|
||||
if (json && !suppressOutput) {
|
||||
console.log(JSON.stringify(jsonResult, null, JSON_SPACES));
|
||||
}
|
||||
if (json && !suppressOutput) {
|
||||
console.log(JSON.stringify(jsonResult, null, JSON_SPACES));
|
||||
}
|
||||
|
||||
return jsonResult;
|
||||
return jsonResult;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type {DriverType, PluginType} from '@appium/types';
|
||||
import type {CliExtensionSubcommand} from 'appium/types';
|
||||
import type {CliExtensionSubcommand} from 'appium/types/index.js';
|
||||
import {ArgumentParser} from 'argparse';
|
||||
import type {SubArgumentParserOptions, SubparsersAction} from 'argparse';
|
||||
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
PLUGIN_TYPE,
|
||||
SERVER_SUBCOMMAND,
|
||||
SETUP_SUBCOMMAND,
|
||||
} from '../constants';
|
||||
import {APPIUM_VER} from '../helpers/build';
|
||||
import {finalizeSchema, getAllArgSpecs, getArgSpec, hasArgSpec} from '../schema';
|
||||
import {setPath} from '../utils';
|
||||
import {getExtensionArgs, getServerArgs} from './args';
|
||||
import type {ArgumentDefinitions} from './args';
|
||||
} from '../constants.js';
|
||||
import {APPIUM_VER} from '../helpers/build.js';
|
||||
import type {ArgumentDefinitions} from '../schema/index.js';
|
||||
import {finalizeSchema, getAllArgSpecs, getArgSpec, hasArgSpec} from '../schema/index.js';
|
||||
import {setPath} from '../utils/index.js';
|
||||
import {getExtensionArgs, getServerArgs} from './args.js';
|
||||
import {
|
||||
DEFAULT_PLUGINS,
|
||||
determinePlatformName,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
SUBCOMMAND_DESKTOP,
|
||||
SUBCOMMAND_MOBILE,
|
||||
SUBCOMMAND_RESET,
|
||||
} from './setup-command';
|
||||
} from './setup-command.js';
|
||||
|
||||
export const EXTRA_ARGS = 'extraArgs';
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {console, util} from '@appium/support';
|
||||
import type {ExtMetadata, ExtRecord, InstallType} from 'appium/types';
|
||||
import type {ExtMetadata, ExtRecord, InstallType} from 'appium/types/index.js';
|
||||
|
||||
import {KNOWN_PLUGINS} from '../constants';
|
||||
import {ExtensionCliCommand} from './extension-command';
|
||||
import {KNOWN_PLUGINS} from '../constants.js';
|
||||
import {ExtensionCliCommand} from './extension-command.js';
|
||||
import type {
|
||||
ExtensionArgs,
|
||||
ExtensionCommandOptions,
|
||||
ExtensionUpdateResult,
|
||||
PostInstallText,
|
||||
RunOutput,
|
||||
} from './extension-command';
|
||||
} from './extension-command.js';
|
||||
|
||||
const REQ_PLUGIN_FIELDS = ['pluginName', 'mainClass'];
|
||||
type PluginInstallOpts = {plugin: string; installType: InstallType; packageName?: string};
|
||||
|
||||
@@ -5,12 +5,12 @@ import type {
|
||||
CliCommandSetupSubcommand,
|
||||
CliExtensionCommand,
|
||||
CliExtensionSubcommand,
|
||||
} from 'appium/types';
|
||||
} from 'appium/types/index.js';
|
||||
|
||||
import {DESKTOP_BROWSERS, DESKTOP_DRIVERS, MOBILE_DRIVERS} from '../constants';
|
||||
import type {ExtensionConfig} from '../extension/extension-config';
|
||||
import {log} from '../logger';
|
||||
import {runExtensionCommand} from './extension';
|
||||
import {DESKTOP_BROWSERS, DESKTOP_DRIVERS, MOBILE_DRIVERS} from '../constants.js';
|
||||
import type {ExtensionConfig} from '../extension/extension-config.js';
|
||||
import {log} from '../logger.js';
|
||||
import {runExtensionCommand} from './extension.js';
|
||||
|
||||
/**
|
||||
* Subcommands of preset for setup
|
||||
|
||||
@@ -75,6 +75,11 @@ export const CACHE_DIR_RELATIVE_PATH = path.join('node_modules', '.cache', 'appi
|
||||
*/
|
||||
export const PKG_HASHFILE_RELATIVE_PATH = path.join(CACHE_DIR_RELATIVE_PATH, 'package.hash');
|
||||
|
||||
/**
|
||||
* Relative path to lockfile used when installing an extension via `appium`
|
||||
*/
|
||||
export const INSTALL_LOCKFILE_RELATIVE_PATH = path.join(CACHE_DIR_RELATIVE_PATH, '.install.lock');
|
||||
|
||||
export const EXT_SUBCOMMAND_LIST = 'list';
|
||||
export const EXT_SUBCOMMAND_INSTALL = 'install';
|
||||
export const EXT_SUBCOMMAND_UNINSTALL = 'uninstall';
|
||||
@@ -110,3 +115,8 @@ export const BIDI_EVENT_NAME = 'bidiEvent';
|
||||
* The name of the insecure feature that allows retrieving the list of active server sessions
|
||||
*/
|
||||
export const SESSION_DISCOVERY_FEATURE = 'session_discovery';
|
||||
|
||||
/**
|
||||
* The name of the insecure feature that allows CORS requests from any host
|
||||
*/
|
||||
export const CORS_FEATURE = 'cors';
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import {util} from '@appium/support';
|
||||
import type {DriverClass, DriverType, StringRecord} from '@appium/types';
|
||||
import type {ExtManifest, ExtName, ExtRecord} from 'appium/types';
|
||||
import type {ExtManifest, ExtName, ExtRecord} from 'appium/types/index.js';
|
||||
|
||||
import {DRIVER_TYPE} from '../constants';
|
||||
import {log} from '../logger';
|
||||
import type {ExtManifestProblem} from './extension-config';
|
||||
import {ExtensionConfig} from './extension-config';
|
||||
import type {Manifest} from './manifest';
|
||||
import {DRIVER_TYPE} from '../constants.js';
|
||||
import {log} from '../logger.js';
|
||||
import {ExtensionConfig} from './extension-config.js';
|
||||
import {manifestValidator} from './manifest/index.js';
|
||||
import type {ExtManifestProblem, Manifest} from './manifest/index.js';
|
||||
|
||||
export type MatchedDriver = {
|
||||
driver: DriverClass;
|
||||
@@ -87,46 +86,21 @@ export class DriverConfig extends ExtensionConfig<DriverType> {
|
||||
|
||||
protected override getConfigProblems(extManifest: ExtManifest<DriverType>, extName: string): ExtManifestProblem[] {
|
||||
void extName;
|
||||
const problems: ExtManifestProblem[] = [];
|
||||
const {platformNames, automationName} = extManifest;
|
||||
const problems = manifestValidator.getDriverManifestProblems(extManifest);
|
||||
// `?.`, not destructuring: a corrupted entry may be null/non-object (already reported above).
|
||||
const automationName = (extManifest as unknown as Record<string, unknown> | null | undefined)?.automationName;
|
||||
|
||||
if (!Array.isArray(platformNames)) {
|
||||
problems.push({
|
||||
err: 'Missing or incorrect supported platformNames list.',
|
||||
val: platformNames,
|
||||
});
|
||||
} else if (util.isEmpty(platformNames)) {
|
||||
problems.push({
|
||||
err: 'Empty platformNames list.',
|
||||
val: platformNames,
|
||||
});
|
||||
} else {
|
||||
for (const pName of platformNames) {
|
||||
if (typeof pName !== 'string') {
|
||||
problems.push({
|
||||
err: 'Incorrectly formatted platformName.',
|
||||
val: pName,
|
||||
});
|
||||
}
|
||||
// Only track actual strings — `undefined` would make every automationName-less entry a "duplicate".
|
||||
if (typeof automationName === 'string') {
|
||||
if (this.knownAutomationNames.has(automationName)) {
|
||||
problems.push({
|
||||
err: 'Multiple drivers claim support for the same automationName',
|
||||
val: automationName,
|
||||
});
|
||||
}
|
||||
this.knownAutomationNames.add(automationName);
|
||||
}
|
||||
|
||||
if (typeof automationName !== 'string') {
|
||||
problems.push({
|
||||
err: 'Missing or incorrect automationName',
|
||||
val: automationName,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.knownAutomationNames.has(automationName as string)) {
|
||||
problems.push({
|
||||
err: 'Multiple drivers claim support for the same automationName',
|
||||
val: automationName,
|
||||
});
|
||||
}
|
||||
|
||||
this.knownAutomationNames.add(automationName as string);
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {createRequire} from 'node:module';
|
||||
import path from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
|
||||
import {fs, system, util} from '@appium/support';
|
||||
import type {ExtensionType, StringRecord} from '@appium/types';
|
||||
import type {SchemaObject} from 'ajv';
|
||||
import type {ExtClass, ExtManifest, ExtName, ExtRecord, InstallType} from 'appium/types';
|
||||
import type {ExtClass, ExtManifest, ExtName, ExtRecord} from 'appium/types/index.js';
|
||||
import {satisfies} from 'semver';
|
||||
|
||||
import type {
|
||||
@@ -12,51 +12,19 @@ import type {
|
||||
ExtensionListData,
|
||||
InstalledExtensionListData,
|
||||
ExtensionCliCommand,
|
||||
} from '../cli/extension-command';
|
||||
import {APPIUM_VER} from '../helpers/build';
|
||||
import {log} from '../logger';
|
||||
import {ALLOWED_SCHEMA_EXTENSIONS, isAllowedSchemaFileExtension, registerSchema} from '../schema/schema';
|
||||
import {capitalize, resolveFrom} from '../utils';
|
||||
import type {Manifest} from './manifest';
|
||||
} from '../cli/extension-command.js';
|
||||
import {APPIUM_VER} from '../helpers/build.js';
|
||||
import {log} from '../logger.js';
|
||||
import type {SchemaObject} from '../schema/ajv.js';
|
||||
import {ALLOWED_SCHEMA_EXTENSIONS, isAllowedSchemaFileExtension, registerSchema} from '../schema/schema.js';
|
||||
import {capitalize, resolveFrom} from '../utils/index.js';
|
||||
import {INSTALL_TYPES, manifestValidator} from './manifest/index.js';
|
||||
import type {ExtManifestProblem, Manifest} from './manifest/index.js';
|
||||
|
||||
const DEFAULT_ENTRY_POINT = 'index.js';
|
||||
/**
|
||||
* "npm" install type
|
||||
* Used when extension was installed by npm package name
|
||||
* @remarks _All_ extensions are installed _by_ `npm`, but only this one means the package name was
|
||||
* used to specify it
|
||||
*/
|
||||
export const INSTALL_TYPE_NPM = 'npm';
|
||||
/**
|
||||
* "local" install type
|
||||
* Used when extension was installed from a local path
|
||||
*/
|
||||
export const INSTALL_TYPE_LOCAL = 'local';
|
||||
/**
|
||||
* "github" install type
|
||||
* Used when extension was installed via GitHub URL
|
||||
*/
|
||||
export const INSTALL_TYPE_GITHUB = 'github';
|
||||
/**
|
||||
* "git" install type
|
||||
* Used when extensions was installed via Git URL
|
||||
*/
|
||||
export const INSTALL_TYPE_GIT = 'git';
|
||||
/**
|
||||
* "dev" install type
|
||||
* Used when automatically detected as a working copy
|
||||
*/
|
||||
export const INSTALL_TYPE_DEV = 'dev';
|
||||
|
||||
export const INSTALL_TYPES = new Set<InstallType>([
|
||||
INSTALL_TYPE_GIT,
|
||||
INSTALL_TYPE_GITHUB,
|
||||
INSTALL_TYPE_LOCAL,
|
||||
INSTALL_TYPE_NPM,
|
||||
INSTALL_TYPE_DEV,
|
||||
]);
|
||||
|
||||
export type ExtManifestProblem = {err: string; val: unknown};
|
||||
// Counter for `APPIUM_RELOAD_EXTENSIONS` cache-busting; `Date.now()` alone can collide when two
|
||||
// reloads happen within the same millisecond, which would serve the stale cached module.
|
||||
let reloadCounter = 0;
|
||||
|
||||
export type ExtManifestWithSchema<E extends ExtensionType> = ExtManifest<E> & {
|
||||
schema: NonNullable<ExtManifest<E>['schema']>;
|
||||
@@ -64,6 +32,31 @@ export type ExtManifestWithSchema<E extends ExtensionType> = ExtManifest<E> & {
|
||||
|
||||
export type ExtensionConfigMutationOpts = {write?: boolean};
|
||||
|
||||
/**
|
||||
* Results of the most recent {@linkcode ExtensionConfig.validate} call, not yet rendered anywhere.
|
||||
* `validate()` only computes this; call {@linkcode ExtensionConfig.printValidationSummary} to display it,
|
||||
* so the caller can pick a sink appropriate to its own output context (e.g. a JSON-mode-aware CLI
|
||||
* console, or the server's Winston-backed logger).
|
||||
*/
|
||||
interface ExtensionValidationSummary {
|
||||
/** Number of extensions considered; used only for pluralizing the summary's lead-in line. */
|
||||
checkedCount: number;
|
||||
errorSummaries: string[];
|
||||
warningSummaries: string[];
|
||||
}
|
||||
|
||||
/** Minimal logger shape needed to render a {@linkcode ExtensionValidationSummary}. */
|
||||
export type ValidationSummarySink = {
|
||||
warn(message?: string, ...args: any[]): void;
|
||||
error(message?: string, ...args: any[]): void;
|
||||
};
|
||||
|
||||
const EMPTY_VALIDATION_SUMMARY: ExtensionValidationSummary = {
|
||||
checkedCount: 0,
|
||||
errorSummaries: [],
|
||||
warningSummaries: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared configuration and validation for installed Appium extensions (drivers or plugins).
|
||||
* Subclasses fix the extension kind; do not instantiate this class directly.
|
||||
@@ -71,13 +64,21 @@ export type ExtensionConfigMutationOpts = {write?: boolean};
|
||||
export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
readonly extensionType: ExtType;
|
||||
readonly manifest: Manifest;
|
||||
installedExtensions: ExtRecord<ExtType>;
|
||||
#listDataCache: ExtensionList<ExtType> | undefined;
|
||||
/** Populated by {@linkcode ExtensionConfig.validate}; see {@linkcode ExtensionValidationSummary}. */
|
||||
private validationSummary: ExtensionValidationSummary = EMPTY_VALIDATION_SUMMARY;
|
||||
private listDataCache: ExtensionList<ExtType> | undefined;
|
||||
|
||||
protected constructor(extensionType: ExtType, manifest: Manifest) {
|
||||
this.extensionType = extensionType;
|
||||
this.manifest = manifest;
|
||||
this.installedExtensions = manifest.getExtensionData(extensionType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live installed-extension map for this extension kind (same object the manifest holds).
|
||||
* Recomputed on every access so it stays in sync across a {@link Manifest.read} reload.
|
||||
*/
|
||||
get installedExtensions(): ExtRecord<ExtType> {
|
||||
return this.manifest.getExtensionData(this.extensionType);
|
||||
}
|
||||
|
||||
/** Path to `extensions.yaml` after the manifest has been read; otherwise undefined. */
|
||||
@@ -119,7 +120,16 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
let moduleObject: any;
|
||||
if (typeof argSchemaPath === 'string') {
|
||||
const schemaPath = await resolveFrom(appiumHome, path.join(pkgName, argSchemaPath));
|
||||
moduleObject = require(schemaPath);
|
||||
if (path.extname(schemaPath) === '.json') {
|
||||
// `import()` of JSON needs an import attribute Node versions disagree on the
|
||||
// syntax for; parsing directly avoids that entirely.
|
||||
moduleObject = JSON.parse(await fs.readFile(schemaPath, 'utf8'));
|
||||
} else {
|
||||
// https://github.com/nodejs/node/issues/31710
|
||||
const importPath = system.isWindows() ? pathToFileURL(schemaPath).href : schemaPath;
|
||||
const mod = (await import(importPath)) as Record<string, any>;
|
||||
moduleObject = 'default' in mod ? mod.default : mod;
|
||||
}
|
||||
} else {
|
||||
moduleObject = argSchemaPath;
|
||||
}
|
||||
@@ -296,7 +306,23 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
const [reqPath, mainClass] = await this._resolveExtension(extName);
|
||||
log.debug(`Requiring ${this.extensionType} at ${reqPath}`);
|
||||
// https://github.com/nodejs/node/issues/31710
|
||||
const importPath = system.isWindows() ? pathToFileURL(reqPath).href : reqPath;
|
||||
let importPath = system.isWindows() ? pathToFileURL(reqPath).href : reqPath;
|
||||
// note: this will only reload the entry point, not files it imports internally
|
||||
if (process.env.APPIUM_RELOAD_EXTENSIONS) {
|
||||
// For a CJS extension, `import()` delegates to Node's CJS loader, which caches by
|
||||
// resolved filename and ignores the query string appended below — evict it from
|
||||
// `require.cache` directly so it's actually re-evaluated. (No-op for a genuinely ESM
|
||||
// extension, since it was never in `require.cache` to begin with.)
|
||||
const req = createRequire(import.meta.url);
|
||||
const realEntryPath = await fs.realpath(reqPath);
|
||||
if (req.cache[realEntryPath]) {
|
||||
delete req.cache[realEntryPath];
|
||||
}
|
||||
// For an ESM extension, there's no public API to evict a module from ESM's registry, so
|
||||
// force a fresh copy via a unique specifier instead.
|
||||
importPath += `?reload=${reloadCounter++}`;
|
||||
log.debug(`Reloading ${this.extensionType} at ${reqPath}`);
|
||||
}
|
||||
const mod = (await import(importPath)) as Record<string, ExtClass<ExtType>>;
|
||||
const MainClass = mod[mainClass];
|
||||
if (!MainClass) {
|
||||
@@ -333,8 +359,8 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates all entries in `exts`, logs summaries, and removes keys that have blocking errors.
|
||||
* Intended for subclasses’ `validate` implementation.
|
||||
* Validates all entries in `exts`, records a summary (see {@linkcode ExtensionConfig.printValidationSummary}),
|
||||
* and removes keys that have blocking errors. Intended for subclasses’ `validate` implementation.
|
||||
*/
|
||||
protected async _validate(exts: ExtRecord<ExtType>): Promise<ExtRecord<ExtType>> {
|
||||
const errorMap = new Map<string, ExtManifestProblem[]>();
|
||||
@@ -353,47 +379,61 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
}
|
||||
|
||||
const {errorSummaries, warningSummaries} = this.getValidationResultSummaries(errorMap, warningMap);
|
||||
this.validationSummary = {checkedCount: errorMap.size, errorSummaries, warningSummaries};
|
||||
return exts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the summary from the most recent {@linkcode ExtensionConfig.validate} call via `sink`,
|
||||
* then clears it so a later call doesn't re-print the same summary. Errors take precedence:
|
||||
* warnings are only shown when there were no errors. No-op if there's nothing to report.
|
||||
*
|
||||
* @param sink - Where to write the summary; e.g. a JSON-mode-aware CLI console for extension
|
||||
* subcommands, or the server's own (by then Winston-backed) logger once it has taken over.
|
||||
*/
|
||||
printValidationSummary(sink: ValidationSummarySink): void {
|
||||
const {checkedCount, errorSummaries, warningSummaries} = this.validationSummary;
|
||||
this.validationSummary = EMPTY_VALIDATION_SUMMARY;
|
||||
|
||||
if (!util.isEmpty(errorSummaries)) {
|
||||
log.error(
|
||||
sink.error(
|
||||
`Appium encountered ${util.pluralize(
|
||||
'error',
|
||||
errorMap.size,
|
||||
checkedCount,
|
||||
true,
|
||||
)} while validating ${this.extensionType}s found in manifest ${this.manifestPath}`,
|
||||
);
|
||||
for (const summary of errorSummaries) {
|
||||
log.error(summary);
|
||||
sink.error(summary);
|
||||
}
|
||||
} else if (!util.isEmpty(warningSummaries)) {
|
||||
// only display warnings if there are no errors!
|
||||
log.warn(
|
||||
sink.warn(
|
||||
`Appium encountered ${util.pluralize(
|
||||
'warning',
|
||||
warningMap.size,
|
||||
checkedCount,
|
||||
true,
|
||||
)} while validating ${this.extensionType}s found in manifest ${this.manifestPath}`,
|
||||
);
|
||||
for (const summary of warningSummaries) {
|
||||
log.warn(summary);
|
||||
sink.warn(summary);
|
||||
}
|
||||
}
|
||||
return exts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches `appium driver|plugin list`-style data via the CLI command class; result is cached.
|
||||
*/
|
||||
protected async getListData(): Promise<ExtensionList<ExtType>> {
|
||||
if (this.#listDataCache) {
|
||||
return this.#listDataCache;
|
||||
if (this.listDataCache) {
|
||||
return this.listDataCache;
|
||||
}
|
||||
// Import here to avoid circular dependency with cli/extension
|
||||
const {commandClasses} = await import('../cli/extension.js');
|
||||
const CommandClass = (commandClasses as StringRecord)[this.extensionType];
|
||||
const cmd = new CommandClass({config: this, json: true}) as ExtensionCliCommand<ExtType>;
|
||||
const listData = await cmd.list({showInstalled: true, showUpdates: true});
|
||||
this.#listDataCache = listData;
|
||||
this.listDataCache = listData;
|
||||
return listData;
|
||||
}
|
||||
|
||||
@@ -429,7 +469,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) {
|
||||
@@ -513,31 +553,7 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
/** Blocking issues for required manifest fields shared by all extensions (version, package name, main class). */
|
||||
protected getGenericConfigProblems(extManifest: ExtManifest<ExtType>, extName: string): ExtManifestProblem[] {
|
||||
void extName;
|
||||
const {version, pkgName, mainClass} = extManifest;
|
||||
const problems: ExtManifestProblem[] = [];
|
||||
|
||||
if (typeof version !== 'string') {
|
||||
problems.push({
|
||||
err: `Invalid or missing \`version\` field in my \`package.json\` and/or \`extensions.yaml\` (must be a string)`,
|
||||
val: version,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof pkgName !== 'string') {
|
||||
problems.push({
|
||||
err: `Invalid or missing \`name\` field in my \`package.json\` and/or \`extensions.yaml\` (must be a string)`,
|
||||
val: pkgName,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof mainClass !== 'string') {
|
||||
problems.push({
|
||||
err: `Invalid or missing \`appium.mainClass\` field in my \`package.json\` and/or \`mainClass\` field in \`extensions.yaml\` (must be a string)`,
|
||||
val: mainClass,
|
||||
});
|
||||
}
|
||||
|
||||
return problems;
|
||||
return manifestValidator.getCommonManifestProblems(extManifest);
|
||||
}
|
||||
|
||||
/** Driver- or plugin-specific blocking validation; override in subclasses when needed. */
|
||||
@@ -578,11 +594,6 @@ export abstract class ExtensionConfig<ExtType extends ExtensionType> {
|
||||
`Assumed entry point: '${entryPointFullPath}'`,
|
||||
);
|
||||
}
|
||||
// note: this will only reload the entry point
|
||||
if (process.env.APPIUM_RELOAD_EXTENSIONS && require.cache[entryPointFullPath]) {
|
||||
log.debug(`Removing ${entryPointFullPath} from require cache`);
|
||||
delete require.cache[entryPointFullPath];
|
||||
}
|
||||
return [entryPointFullPath, mainClass];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {timing, util} from '@appium/support';
|
||||
import type {DriverClass, ExtensionType, PluginClass} from '@appium/types';
|
||||
import type {ExtClass} from 'appium/types';
|
||||
import type {ExtClass} from 'appium/types/index.js';
|
||||
import {asyncmap} from 'asyncbox';
|
||||
|
||||
import {USE_ALL_PLUGINS} from '../constants';
|
||||
import {log} from '../logger';
|
||||
import {zip} from '../utils';
|
||||
import {DriverConfig} from './driver-config';
|
||||
import {Manifest} from './manifest';
|
||||
import {PluginConfig} from './plugin-config';
|
||||
import {USE_ALL_PLUGINS} from '../constants.js';
|
||||
import {log} from '../logger.js';
|
||||
import {withManifestLock, zip} from '../utils/index.js';
|
||||
import {DriverConfig} from './driver-config.js';
|
||||
import {Manifest} from './manifest/index.js';
|
||||
import {PluginConfig} from './plugin-config.js';
|
||||
|
||||
export type ExtensionConfigs = {
|
||||
driverConfig: DriverConfig;
|
||||
@@ -18,6 +18,22 @@ export type ExtensionConfigs = {
|
||||
export type PluginNameMap = Map<PluginClass, string>;
|
||||
export type DriverNameMap = Map<DriverClass, string>;
|
||||
|
||||
/**
|
||||
* Re-reads `manifest` from disk and revalidates whichever `DriverConfig`/`PluginConfig`
|
||||
* singletons already exist for it, so their derived state (installed extensions, pending
|
||||
* validation summary, {@link DriverConfig}'s duplicate-`automationName` tracking) reflects the
|
||||
* freshly-read data rather than a stale pre-reload snapshot.
|
||||
*
|
||||
* Callers must run this under {@link withManifestLock} -- both because `manifest.read()` may
|
||||
* itself write the manifest (initialization, migration, or sync with installed packages), and
|
||||
* because anything a caller does afterward that depends on the result staying current (e.g.
|
||||
* writing the manifest back out) needs to stay in the same critical section.
|
||||
*/
|
||||
export async function reloadManifest(manifest: Manifest): Promise<void> {
|
||||
await manifest.read();
|
||||
await Promise.all([DriverConfig.getInstance(manifest)?.validate(), PluginConfig.getInstance(manifest)?.validate()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads extensions and creates `ExtensionConfig` instances.
|
||||
*
|
||||
@@ -25,15 +41,15 @@ export type DriverNameMap = Map<DriverClass, string>;
|
||||
* - Using the parsed extension data, creates/gets the `ExtensionConfig` subclass instances
|
||||
* - Returns these instances
|
||||
*
|
||||
* If `appiumHome` is needed, use `resolveAppiumHome` from the `env` module in `@appium/support`.
|
||||
* If `appiumHome` is needed, use `resolveAppiumHome` from `../utils/index.js`.
|
||||
*/
|
||||
export async function loadExtensions(appiumHome: string): Promise<ExtensionConfigs> {
|
||||
const manifest = Manifest.getInstance(appiumHome);
|
||||
await manifest.read();
|
||||
const driverConfig = DriverConfig.getInstance(manifest) ?? DriverConfig.create(manifest);
|
||||
const pluginConfig = PluginConfig.getInstance(manifest) ?? PluginConfig.create(manifest);
|
||||
|
||||
await Promise.all([driverConfig.validate(), pluginConfig.validate()]);
|
||||
await withManifestLock(appiumHome, () => reloadManifest(manifest));
|
||||
|
||||
return {driverConfig, pluginConfig};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export {Manifest} from './manifest.js';
|
||||
export {
|
||||
INSTALL_TYPE_DEV,
|
||||
INSTALL_TYPE_GIT,
|
||||
INSTALL_TYPE_GITHUB,
|
||||
INSTALL_TYPE_LOCAL,
|
||||
INSTALL_TYPE_NPM,
|
||||
INSTALL_TYPES,
|
||||
} from './install-types.js';
|
||||
export type {EnvelopeValidationResult, ExtManifestProblem} from './validator.js';
|
||||
export {manifestValidator} from './validator.js';
|
||||
@@ -0,0 +1,37 @@
|
||||
import type {InstallType} from 'appium/types/index.js';
|
||||
|
||||
/**
|
||||
* "npm" install type
|
||||
* Used when extension was installed by npm package name
|
||||
* @remarks _All_ extensions are installed _by_ `npm`, but only this one means the package name was
|
||||
* used to specify it
|
||||
*/
|
||||
export const INSTALL_TYPE_NPM = 'npm';
|
||||
/**
|
||||
* "local" install type
|
||||
* Used when extension was installed from a local path
|
||||
*/
|
||||
export const INSTALL_TYPE_LOCAL = 'local';
|
||||
/**
|
||||
* "github" install type
|
||||
* Used when extension was installed via GitHub URL
|
||||
*/
|
||||
export const INSTALL_TYPE_GITHUB = 'github';
|
||||
/**
|
||||
* "git" install type
|
||||
* Used when extensions was installed via Git URL
|
||||
*/
|
||||
export const INSTALL_TYPE_GIT = 'git';
|
||||
/**
|
||||
* "dev" install type
|
||||
* Used when automatically detected as a working copy
|
||||
*/
|
||||
export const INSTALL_TYPE_DEV = 'dev';
|
||||
|
||||
export const INSTALL_TYPES = new Set<InstallType>([
|
||||
INSTALL_TYPE_GIT,
|
||||
INSTALL_TYPE_GITHUB,
|
||||
INSTALL_TYPE_LOCAL,
|
||||
INSTALL_TYPE_NPM,
|
||||
INSTALL_TYPE_DEV,
|
||||
]);
|
||||
+76
-12
@@ -1,14 +1,23 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import {env, fs, util} from '@appium/support';
|
||||
import {fs, util} from '@appium/support';
|
||||
import type {DriverType, ExtensionType, PluginType} from '@appium/types';
|
||||
import type {ExtManifest, ExtPackageJson, ExtRecord, InternalMetadata, ManifestData} from 'appium/types';
|
||||
import type {ExtManifest, ExtPackageJson, ExtRecord, InternalMetadata, ManifestData} from 'appium/types/index.js';
|
||||
import {asyncmap} from 'asyncbox';
|
||||
import * as YAML from 'yaml';
|
||||
|
||||
import {CURRENT_SCHEMA_REV, DRIVER_TYPE, PLUGIN_TYPE} from '../constants';
|
||||
import {packageDidChange} from '../utils';
|
||||
import {INSTALL_TYPE_DEV, INSTALL_TYPE_NPM} from './extension-config';
|
||||
import {migrate} from './manifest-migrations';
|
||||
import {CURRENT_SCHEMA_REV, DRIVER_TYPE, PLUGIN_TYPE} from '../../constants.js';
|
||||
import {log} from '../../logger.js';
|
||||
import {
|
||||
hasAppiumDependency as checkHasAppiumDependency,
|
||||
packageDidChange,
|
||||
resolveManifestPath,
|
||||
} from '../../utils/index.js';
|
||||
import {INSTALL_TYPE_DEV, INSTALL_TYPE_NPM} from './install-types.js';
|
||||
import {migrate} from './migrations.js';
|
||||
import {manifestValidator} from './validator.js';
|
||||
|
||||
const MAX_CONCURRENT_FS_TASKS = 10;
|
||||
|
||||
const CONFIG_DATA_DRIVER_KEY = `${DRIVER_TYPE}s` as const;
|
||||
const CONFIG_DATA_PLUGIN_KEY = `${PLUGIN_TYPE}s` as const;
|
||||
@@ -122,13 +131,25 @@ export class Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldWrite) {
|
||||
const {valid, errors} = manifestValidator.validateManifestEnvelope(data);
|
||||
if (!valid) {
|
||||
log.warn(
|
||||
`Appium had trouble validating the extension installation cache file (${manifestPathResolved}); ` +
|
||||
`it will be reset. Specific error(s): ${manifestValidator.describeValidationErrors(errors)}`,
|
||||
);
|
||||
data = structuredClone(INITIAL_MANIFEST_DATA) as ManifestData;
|
||||
shouldWrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.#data = data;
|
||||
|
||||
if (!shouldWrite && (data.schemaRev ?? 0) < CURRENT_SCHEMA_REV) {
|
||||
shouldWrite = await migrate(this);
|
||||
}
|
||||
|
||||
const hasAppiumDependency = await env.hasAppiumDependency(this.appiumHome);
|
||||
const hasAppiumDependency = await checkHasAppiumDependency(this.appiumHome);
|
||||
|
||||
if (shouldWrite || (hasAppiumDependency && (await packageDidChange(this.appiumHome)))) {
|
||||
shouldWrite = (await this.syncWithInstalledExtensions(hasAppiumDependency)) || shouldWrite;
|
||||
@@ -210,10 +231,7 @@ export class Manifest {
|
||||
|
||||
const queue: Promise<void>[] = [onMatch(path.join(this.#appiumHome, 'package.json'), true)];
|
||||
|
||||
const filepaths = await fs.glob('node_modules/{*,@*/*}/package.json', {
|
||||
cwd: this.#appiumHome,
|
||||
absolute: true,
|
||||
});
|
||||
const filepaths = await findNodeModulesPackageJsons(path.join(this.#appiumHome, 'node_modules'));
|
||||
for (const filepath of filepaths) {
|
||||
queue.push(onMatch(filepath));
|
||||
}
|
||||
@@ -324,7 +342,7 @@ export class Manifest {
|
||||
|
||||
async #setManifestPath(): Promise<string> {
|
||||
if (!this.#manifestPath) {
|
||||
const resolved = await env.resolveManifestPath(this.#appiumHome);
|
||||
const resolved = await resolveManifestPath(this.#appiumHome);
|
||||
this.#manifestPath = resolved;
|
||||
|
||||
if (path.relative(this.#appiumHome, resolved).startsWith('.')) {
|
||||
@@ -338,6 +356,52 @@ export class Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds `package.json` paths for packages directly under `node_modules` (and one level deeper for
|
||||
* scoped `@foo/bar` packages). Unlike `fs.glob`, this follows symlinks, which is how `npm install
|
||||
* <local-path>` (and `npm link`) install packages.
|
||||
*/
|
||||
async function findNodeModulesPackageJsons(nodeModulesDir: string): Promise<string[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(nodeModulesDir);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const pkgJsonPathsByEntry = await asyncmap(
|
||||
entries.filter((entry) => !entry.startsWith('.')),
|
||||
async (entry): Promise<string[]> => {
|
||||
const entryPath = path.join(nodeModulesDir, entry);
|
||||
if (!entry.startsWith('@')) {
|
||||
const pkgJsonPath = path.join(entryPath, 'package.json');
|
||||
return (await fs.exists(pkgJsonPath)) ? [pkgJsonPath] : [];
|
||||
}
|
||||
|
||||
let scopedEntries: string[];
|
||||
try {
|
||||
scopedEntries = await fs.readdir(entryPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const scopedPkgJsonPaths = await asyncmap(
|
||||
scopedEntries.filter((scopedEntry) => !scopedEntry.startsWith('.')),
|
||||
async (scopedEntry) => {
|
||||
const pkgJsonPath = path.join(entryPath, scopedEntry, 'package.json');
|
||||
return (await fs.exists(pkgJsonPath)) ? pkgJsonPath : null;
|
||||
},
|
||||
{concurrency: MAX_CONCURRENT_FS_TASKS},
|
||||
);
|
||||
return scopedPkgJsonPaths.filter((pkgJsonPath) => pkgJsonPath !== null);
|
||||
},
|
||||
{concurrency: MAX_CONCURRENT_FS_TASKS},
|
||||
);
|
||||
return pkgJsonPathsByEntry.flat();
|
||||
}
|
||||
|
||||
function isExtension(value: unknown): value is ExtPackageJson<ExtensionType> {
|
||||
return (
|
||||
util.isPlainObject(value) &&
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import type {ManifestDataVersions} from 'appium/types';
|
||||
import type {ManifestDataVersions} from 'appium/types/index.js';
|
||||
|
||||
import {CURRENT_SCHEMA_REV, DRIVER_TYPE, PLUGIN_TYPE} from '../constants';
|
||||
import {log} from '../logger';
|
||||
import type {Manifest} from './manifest';
|
||||
import {CURRENT_SCHEMA_REV, DRIVER_TYPE, PLUGIN_TYPE} from '../../constants.js';
|
||||
import {log} from '../../logger.js';
|
||||
import type {Manifest} from './manifest.js';
|
||||
|
||||
const SCHEMA_REV_3 = 3;
|
||||
const SCHEMA_REV_4 = 4;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {SchemaObject} from '../../schema/ajv.js';
|
||||
|
||||
/**
|
||||
* Shallow shape of the parsed `extensions.yaml` document. Deliberately permissive about the
|
||||
* shape of individual `drivers`/`plugins` entries (see {@link commonExtManifestProblemsSchema}
|
||||
* and {@link driverExtManifestProblemsSchema}) so that old-but-valid manifest revisions are
|
||||
* never rejected here; that's `Manifest.read()`'s job before per-extension validation runs.
|
||||
*/
|
||||
export const manifestEnvelopeSchema: SchemaObject = {
|
||||
type: 'object',
|
||||
required: ['drivers', 'plugins'],
|
||||
properties: {
|
||||
drivers: {type: 'object'},
|
||||
plugins: {type: 'object'},
|
||||
schemaRev: {type: 'integer'},
|
||||
},
|
||||
};
|
||||
|
||||
/** Fields required of every extension manifest entry, regardless of driver/plugin kind. */
|
||||
export const commonExtManifestProblemsSchema: SchemaObject = {
|
||||
type: 'object',
|
||||
required: ['version', 'pkgName', 'mainClass'],
|
||||
properties: {
|
||||
version: {type: 'string'},
|
||||
pkgName: {type: 'string'},
|
||||
mainClass: {type: 'string'},
|
||||
},
|
||||
};
|
||||
|
||||
/** Additional fields required of driver manifest entries. */
|
||||
export const driverExtManifestProblemsSchema: SchemaObject = {
|
||||
type: 'object',
|
||||
required: ['automationName', 'platformNames'],
|
||||
properties: {
|
||||
automationName: {type: 'string'},
|
||||
platformNames: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {type: 'string'},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import {Ajv} from '../../schema/ajv.js';
|
||||
import type {ErrorObject, ValidateFunction} from '../../schema/ajv.js';
|
||||
import {commonExtManifestProblemsSchema, driverExtManifestProblemsSchema, manifestEnvelopeSchema} from './schema.js';
|
||||
|
||||
export interface EnvelopeValidationResult {
|
||||
valid: boolean;
|
||||
errors: ErrorObject[];
|
||||
}
|
||||
|
||||
export type ExtManifestProblem = {err: string; val: unknown};
|
||||
|
||||
/**
|
||||
* Top-level manifest field an ajv error is about. For `required` errors, ajv reports the missing
|
||||
* field in `params.missingProperty` (since `instancePath` points at the object that's missing it,
|
||||
* not the field itself); for every other keyword, `instancePath` points at the offending field
|
||||
* directly, so its first segment is the field name (ignoring any array index after it, e.g. the
|
||||
* `0` in `/platformNames/0`).
|
||||
*/
|
||||
function topLevelProp(error: ErrorObject): string | undefined {
|
||||
if (error.keyword === 'required') {
|
||||
return error.params.missingProperty as string;
|
||||
}
|
||||
return error.instancePath.split('/').filter(Boolean)[0];
|
||||
}
|
||||
|
||||
/** Whether `errors` includes a failure of the root value itself (e.g. a string/array/null instead of an object). */
|
||||
function hasRootTypeError(errors: ErrorObject[]): boolean {
|
||||
return errors.some((error) => error.instancePath === '' && error.keyword === 'type');
|
||||
}
|
||||
|
||||
const MISSING_PLATFORM_NAMES_ERR = 'Missing or incorrect supported platformNames list.';
|
||||
const MISSING_AUTOMATION_NAME_ERR = 'Missing or incorrect automationName';
|
||||
|
||||
/** Validates parsed `extensions.yaml` and individual extension manifest entries via a dedicated Ajv instance. */
|
||||
class ManifestValidator {
|
||||
private readonly validateEnvelope: ValidateFunction;
|
||||
private readonly validateCommonProblems: ValidateFunction;
|
||||
private readonly validateDriverProblems: ValidateFunction;
|
||||
|
||||
constructor() {
|
||||
// Dedicated instance: manifest validation runs before `AppiumSchema` (lib/schema/schema.ts) is
|
||||
// finalized, so that singleton can't be reused here. No `format` keyword, so no ajv-formats.
|
||||
const ajv = new Ajv({allErrors: true});
|
||||
this.validateEnvelope = ajv.compile(manifestEnvelopeSchema);
|
||||
this.validateCommonProblems = ajv.compile(commonExtManifestProblemsSchema);
|
||||
this.validateDriverProblems = ajv.compile(driverExtManifestProblemsSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the top-level shape of a parsed `extensions.yaml` document (before migration).
|
||||
*
|
||||
* @param data - Result of `YAML.parse()` on the manifest file contents
|
||||
*/
|
||||
validateManifestEnvelope(data: unknown): EnvelopeValidationResult {
|
||||
const valid = Boolean(this.validateEnvelope(data));
|
||||
return {valid, errors: valid ? [] : [...(this.validateEnvelope.errors ?? [])]};
|
||||
}
|
||||
|
||||
/** Human-readable summary of ajv errors, tolerating `message` being unset (it's optional on `ErrorObject`). */
|
||||
describeValidationErrors(errors: ErrorObject[]): string {
|
||||
return errors
|
||||
.map((error) => error.message ?? `${error.keyword} error at "${error.instancePath || '/'}"`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking issues for manifest fields shared by all extensions (version, package name, main class).
|
||||
*
|
||||
* @param extManifest - Manifest entry for one extension
|
||||
*/
|
||||
getCommonManifestProblems(extManifest: unknown): ExtManifestProblem[] {
|
||||
// Stored in a variable: branching on the call directly narrows the negative branch to `never`.
|
||||
const valid: boolean = this.validateCommonProblems(extManifest);
|
||||
if (valid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const errors = this.validateCommonProblems.errors ?? [];
|
||||
// A root type error means every field is missing; topLevelProp() can't attribute it to one.
|
||||
const props = hasRootTypeError(errors)
|
||||
? new Set(['version', 'pkgName', 'mainClass'])
|
||||
: new Set(errors.map(topLevelProp));
|
||||
|
||||
// Cast once (extManifest may be non-object/null/undefined), then read safely via `?.`.
|
||||
const manifest = extManifest as Record<string, unknown> | null | undefined;
|
||||
const problems: ExtManifestProblem[] = [];
|
||||
|
||||
if (props.has('version')) {
|
||||
problems.push({
|
||||
err: 'Invalid or missing `version` field in my `package.json` and/or `extensions.yaml` (must be a string)',
|
||||
val: manifest?.version,
|
||||
});
|
||||
}
|
||||
if (props.has('pkgName')) {
|
||||
problems.push({
|
||||
err: 'Invalid or missing `name` field in my `package.json` and/or `extensions.yaml` (must be a string)',
|
||||
val: manifest?.pkgName,
|
||||
});
|
||||
}
|
||||
if (props.has('mainClass')) {
|
||||
problems.push({
|
||||
err: 'Invalid or missing `appium.mainClass` field in my `package.json` and/or `mainClass` field in `extensions.yaml` (must be a string)',
|
||||
val: manifest?.mainClass,
|
||||
});
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking issues for driver-specific manifest fields (automationName, platformNames).
|
||||
* Does not include the cross-driver duplicate-`automationName` check, which needs state
|
||||
* spanning multiple extensions and lives in {@link DriverConfig.getConfigProblems} instead.
|
||||
*
|
||||
* @param extManifest - Manifest entry for one driver
|
||||
*/
|
||||
getDriverManifestProblems(extManifest: unknown): ExtManifestProblem[] {
|
||||
// See the comment in `getCommonManifestProblems` about not narrowing on the call directly.
|
||||
const valid: boolean = this.validateDriverProblems(extManifest);
|
||||
if (valid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const errors = this.validateDriverProblems.errors ?? [];
|
||||
// See the comment in `getCommonManifestProblems` about casting once and reading via `?.`.
|
||||
const manifest = extManifest as Record<string, unknown> | null | undefined;
|
||||
const platformNames = manifest?.platformNames;
|
||||
const automationName = manifest?.automationName;
|
||||
|
||||
// A root type error means both fields are missing; no per-property error to attribute either to.
|
||||
if (hasRootTypeError(errors)) {
|
||||
return [
|
||||
{err: MISSING_PLATFORM_NAMES_ERR, val: platformNames},
|
||||
{err: MISSING_AUTOMATION_NAME_ERR, val: automationName},
|
||||
];
|
||||
}
|
||||
|
||||
const problems: ExtManifestProblem[] = [];
|
||||
const platformNamesErrors = errors.filter((error) => topLevelProp(error) === 'platformNames');
|
||||
const automationNameErrors = errors.filter((error) => topLevelProp(error) === 'automationName');
|
||||
|
||||
const isMissingOrWrongType = platformNamesErrors.some(
|
||||
(error) => error.keyword === 'required' || (error.keyword === 'type' && error.instancePath === '/platformNames'),
|
||||
);
|
||||
const isEmpty = platformNamesErrors.some((error) => error.keyword === 'minItems');
|
||||
|
||||
if (isMissingOrWrongType) {
|
||||
problems.push({err: MISSING_PLATFORM_NAMES_ERR, val: platformNames});
|
||||
} else if (isEmpty) {
|
||||
problems.push({err: 'Empty platformNames list.', val: platformNames});
|
||||
} else {
|
||||
for (const error of platformNamesErrors) {
|
||||
if (error.keyword === 'type') {
|
||||
const index = Number(error.instancePath.split('/')[2]);
|
||||
problems.push({err: 'Incorrectly formatted platformName.', val: (platformNames as unknown[])[index]});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (automationNameErrors.length) {
|
||||
problems.push({err: MISSING_AUTOMATION_NAME_ERR, val: automationName});
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
}
|
||||
|
||||
export const manifestValidator = new ManifestValidator();
|
||||
@@ -1,11 +1,11 @@
|
||||
import {util} from '@appium/support';
|
||||
import type {PluginType} from '@appium/types';
|
||||
import type {ExtManifest, ExtName, ExtRecord} from 'appium/types';
|
||||
import type {ExtManifest, ExtName, ExtRecord} from 'appium/types/index.js';
|
||||
|
||||
import {PLUGIN_TYPE} from '../constants';
|
||||
import {log} from '../logger';
|
||||
import {ExtensionConfig} from './extension-config';
|
||||
import type {Manifest} from './manifest';
|
||||
import {PLUGIN_TYPE} from '../constants.js';
|
||||
import {log} from '../logger.js';
|
||||
import {ExtensionConfig} from './extension-config.js';
|
||||
import type {Manifest} from './manifest/index.js';
|
||||
|
||||
export class PluginConfig extends ExtensionConfig<PluginType> {
|
||||
private static readonly _instances = new WeakMap<Manifest, PluginConfig>();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {fs, system, util} from '@appium/support';
|
||||
import type {BuildInfo} from 'appium/types';
|
||||
import type {BuildInfo} from 'appium/types/index.js';
|
||||
import axios from 'axios';
|
||||
import {exec} from 'teen_process';
|
||||
|
||||
import {npmPackage} from '../utils';
|
||||
import {npmPackage} from '../utils/index.js';
|
||||
|
||||
export const APPIUM_VER = npmPackage.version;
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function getGitRev(useGithubApiFallback = false): Promise<string |
|
||||
if (fullGitPath) {
|
||||
try {
|
||||
const {stdout} = await exec(fullGitPath, ['rev-parse', 'HEAD'], {
|
||||
cwd: __dirname,
|
||||
cwd: import.meta.dirname,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch {}
|
||||
@@ -58,7 +58,7 @@ async function getGitTimestamp(commitSha: string, useGithubApiFallback = false):
|
||||
if (fullGitPath) {
|
||||
try {
|
||||
const {stdout} = await exec(fullGitPath, ['show', '-s', '--format=%ci', commitSha], {
|
||||
cwd: __dirname,
|
||||
cwd: import.meta.dirname,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch {}
|
||||
|
||||
@@ -2,8 +2,8 @@ import {errors, isW3cCaps, processCapabilities, STANDARD_CAPS} from '@appium/bas
|
||||
import {util} from '@appium/support';
|
||||
import type {BaseDriverCapConstraints, Capabilities, Constraints, NSCapabilities, W3CCapabilities} from '@appium/types';
|
||||
|
||||
import {log as logger} from '../logger';
|
||||
import {mapKeys} from '../utils';
|
||||
import {log as logger} from '../logger.js';
|
||||
import {mapKeys} from '../utils/index.js';
|
||||
|
||||
const W3C_APPIUM_PREFIX = 'appium';
|
||||
const STANDARD_CAPS_LOWERCASE = new Set([...STANDARD_CAPS].map((cap) => cap.toLowerCase()));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {util} from '@appium/support';
|
||||
import type {ExternalDriver} from '@appium/types';
|
||||
|
||||
import type {AppiumDriver} from './appium';
|
||||
import {log as logger} from './logger';
|
||||
import type {AppiumDriver} from './appium.js';
|
||||
import {log as logger} from './logger.js';
|
||||
|
||||
const ALL_DRIVERS_MATCH = '*';
|
||||
const FEATURE_NAME_SEPARATOR = ':';
|
||||
|
||||
@@ -16,8 +16,8 @@ import type {
|
||||
RestMethodsToCommandsMap,
|
||||
} from '@appium/types';
|
||||
|
||||
import type {AppiumDriver} from './appium';
|
||||
import {mapValues} from './utils';
|
||||
import type {AppiumDriver} from './appium.js';
|
||||
import {mapValues} from './utils/index.js';
|
||||
|
||||
/**
|
||||
* Returns available REST and BiDi commands for base, driver and plugins.
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type {MessageObject} from '@appium/logger';
|
||||
import globalLog from '@appium/logger';
|
||||
import {log as globalLog, type MessageObject} from '@appium/logger';
|
||||
import {fs, util} from '@appium/support';
|
||||
import type {ParsedArgs} from 'appium/types';
|
||||
import type {ParsedArgs} from 'appium/types/index.js';
|
||||
import {LRUCache} from 'lru-cache';
|
||||
import type {Logform, Logger} from 'winston';
|
||||
import {createLogger, format, transports} from 'winston';
|
||||
import type Transport from 'winston-transport';
|
||||
|
||||
import {adler32} from './utils';
|
||||
import {adler32} from './utils/index.js';
|
||||
|
||||
const LEVELS_MAP = {
|
||||
debug: 4,
|
||||
@@ -112,8 +111,9 @@ export async function init(args: ParsedArgs): Promise<void> {
|
||||
}
|
||||
}
|
||||
});
|
||||
// Only Winston produces output; avoid duplicate lines from the logger's default stream
|
||||
// Only Winston produces output; avoid duplicate lines from the logger's default streams
|
||||
globalLog.stream = null;
|
||||
globalLog.errorStream = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +204,7 @@ async function createTransports(args: ParsedArgs): Promise<Transport[]> {
|
||||
|
||||
// Server args are normalized in main so we only see dest form (`loglevel`).
|
||||
// Fall back to schema default so Winston never sees undefined.
|
||||
const rawLogLevel = args.loglevel ?? 'debug';
|
||||
const rawLogLevel = args.loglevel ?? 'info';
|
||||
|
||||
if (rawLogLevel && rawLogLevel.includes(':')) {
|
||||
// --log-level arg can optionally provide diff logging levels for console and file, separated by a colon
|
||||
|
||||
+23
-13
@@ -1,14 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import './logsink'; // must run first: global npmlog / log sink setup (see logsink module)
|
||||
import './logger'; // load Appium logger immediately after logsink (order matters for log wiring)
|
||||
import {env} from '@appium/support';
|
||||
import type {AppiumServer} from '@appium/types';
|
||||
import type {Args, CliCommand, CliCommandServer, CliCommandSetupSubcommand, CliExtensionSubcommand} from 'appium/types';
|
||||
import {realpathSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
import {AppiumInitializer} from './bootstrap/appium-initializer';
|
||||
import {AppiumMainRunner} from './bootstrap/appium-main-runner';
|
||||
import type {ExtCommandInitResult, InitResult, ServerInitData} from './bootstrap/init-types';
|
||||
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';
|
||||
import type {
|
||||
Args,
|
||||
CliCommand,
|
||||
CliCommandServer,
|
||||
CliCommandSetupSubcommand,
|
||||
CliExtensionSubcommand,
|
||||
} from 'appium/types/index.js';
|
||||
|
||||
import {AppiumInitializer} from './bootstrap/appium-initializer.js';
|
||||
import {AppiumMainRunner} from './bootstrap/appium-main-runner.js';
|
||||
import type {ExtCommandInitResult, InitResult, ServerInitData} from './bootstrap/init-types.js';
|
||||
import {resolveAppiumHome as resolveAppiumHomeUtil} from './utils/index.js';
|
||||
|
||||
const initializer = new AppiumInitializer();
|
||||
const mainRunner = new AppiumMainRunner();
|
||||
@@ -40,16 +49,17 @@ 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 be `require.main`.
|
||||
if (require.main === module) {
|
||||
// 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])) {
|
||||
void main();
|
||||
}
|
||||
|
||||
// Re-export helpers from the same package so `import { … } from 'appium'` stays a supported
|
||||
// programmatic API (this file is the package `types` entry). The monorepo does not import these
|
||||
// from `'appium'`; consumers use local paths or `@appium/support`. Dropping them is semver-major.
|
||||
export {readConfigFile} from './bootstrap/config-file';
|
||||
export {finalizeSchema, getSchema, validate} from './schema/schema';
|
||||
export const resolveAppiumHome = env.resolveAppiumHome;
|
||||
export {readConfigFile} from './bootstrap/config-file.js';
|
||||
export {finalizeSchema, getSchema, validate} from './schema/schema.js';
|
||||
export const resolveAppiumHome = resolveAppiumHomeUtil;
|
||||
|
||||
export type {ExtCommandInitResult, InitResult, ServerInitData};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import AjvImport from 'ajv';
|
||||
import addFormatsImport from 'ajv-formats';
|
||||
|
||||
export type {ErrorObject, KeywordDefinition, SchemaObject, ValidateFunction} from 'ajv';
|
||||
|
||||
// `ajv`/`ajv-formats` are CJS with no ESM types; nodenext types their default imports as the
|
||||
// whole module namespace, so re-derive the real constructor/function types via indexed access
|
||||
// and cast the values to match.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
export type AjvCtor = (typeof import('ajv'))['default'];
|
||||
export type AjvInstance = InstanceType<AjvCtor>;
|
||||
export const Ajv = AjvImport as unknown as AjvCtor;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
type AddFormatsFn = (typeof import('ajv-formats'))['default'];
|
||||
export const addFormats = addFormatsImport as unknown as AddFormatsFn;
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {ExtensionType} from '@appium/types';
|
||||
|
||||
import {camelCase, kebabCase} from '../utils';
|
||||
import {camelCase, kebabCase} from '../utils/index.js';
|
||||
|
||||
/**
|
||||
* The original ID of the Appium config schema.
|
||||
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
CliCommandSetupSubcommand,
|
||||
CliExtensionCommand,
|
||||
CliExtensionSubcommand,
|
||||
} from 'appium/types';
|
||||
} from 'appium/types/index.js';
|
||||
|
||||
import {DRIVER_TYPE, PLUGIN_TYPE, SERVER_SUBCOMMAND, SETUP_SUBCOMMAND} from '../constants';
|
||||
import {DRIVER_TYPE, PLUGIN_TYPE, SERVER_SUBCOMMAND, SETUP_SUBCOMMAND} from '../constants.js';
|
||||
|
||||
type AnyArgs = Args<CliCommand, CliExtensionSubcommand | CliCommandSetupSubcommand | void>;
|
||||
|
||||
|
||||
@@ -2,13 +2,18 @@ import {util} from '@appium/support';
|
||||
import {type ArgumentOptions, ArgumentTypeError} from 'argparse';
|
||||
import type {JSONSchema7, JSONSchema7TypeName} from 'json-schema';
|
||||
|
||||
import type {ArgumentDefinitions} from '../cli/args';
|
||||
import {kebabCase} from '../utils';
|
||||
import type {ArgSpec} from './arg-spec';
|
||||
import {parseCsvLine, transformers} from './cli-transformers';
|
||||
import {formatErrors} from './format-errors';
|
||||
import type {AppiumCliTransformerName, AppiumJSONSchemaKeywords} from './keywords';
|
||||
import {flattenSchema, validate} from './schema';
|
||||
import {kebabCase} from '../utils/index.js';
|
||||
import type {ArgSpec} from './arg-spec.js';
|
||||
import {
|
||||
parseCsvLine,
|
||||
transformers,
|
||||
type AppiumCliTransformerName,
|
||||
type AppiumJSONSchemaKeywords,
|
||||
} from './cli-transformers.js';
|
||||
import {formatErrors} from './format-errors.js';
|
||||
import {flattenSchema, validate} from './schema.js';
|
||||
|
||||
export type ArgumentDefinitions = Map<[name: string] | [name: string, alias: string], ArgumentOptions>;
|
||||
|
||||
type AppiumJSONSchema = AppiumJSONSchemaKeywords & JSONSchema7;
|
||||
type ArgDef = [[string] | [string, string], ArgumentOptions];
|
||||
@@ -64,7 +69,9 @@ function getSchemaValidator<Coerced>(
|
||||
if (util.isEmpty(errors)) {
|
||||
return coerced;
|
||||
}
|
||||
throw new ArgumentTypeError('\n\n' + formatErrors(errors, value, {schemaId}));
|
||||
// the error formatter needs the parsed data (not the raw string) to locate errors inside objects
|
||||
const data = util.isPlainObject(coerced) ? coerced : value;
|
||||
throw new ArgumentTypeError('\n\n' + formatErrors(errors, data, {schemaId}));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,13 +111,15 @@ function subSchemaToArgDef(subSchema: AppiumJSONSchema, argSpec: ArgSpec): ArgDe
|
||||
break;
|
||||
}
|
||||
case TYPENAMES.OBJECT: {
|
||||
argTypeFunction = (value: string) => {
|
||||
argTypeFunction = getSchemaValidator(argSpec, (value: string) => {
|
||||
const o = transformers.json(value);
|
||||
if (!util.isPlainObject(o)) {
|
||||
throw new ArgumentTypeError(`'${util.truncateString(String(o), {length: 100})}' must be a plain object`);
|
||||
throw new ArgumentTypeError(
|
||||
`'${util.truncateString(JSON.stringify(o), {length: 100})}' must be a plain object`,
|
||||
);
|
||||
}
|
||||
return o;
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TYPENAMES.ARRAY: {
|
||||
|
||||
@@ -70,6 +70,17 @@ export const transformers = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type AppiumCliTransformerName = keyof typeof transformers;
|
||||
|
||||
export interface AppiumJSONSchemaKeywords {
|
||||
appiumCliDest?: string;
|
||||
appiumCliDescription?: string;
|
||||
appiumCliAliases?: string[];
|
||||
appiumCliIgnored?: boolean;
|
||||
appiumCliTransformer?: AppiumCliTransformerName;
|
||||
appiumDeprecated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `filepath` is an existing regular file.
|
||||
*
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type {NormalizedAppiumConfig} from '@appium/types';
|
||||
import betterAjvErrors, {type IOutputError} from '@sidvind/better-ajv-errors';
|
||||
import type {ErrorObject} from 'ajv';
|
||||
import betterAjvErrorsImport, {type IOutputError} from '@sidvind/better-ajv-errors';
|
||||
|
||||
import {getSchema} from './schema';
|
||||
import type {ErrorObject} from './ajv.js';
|
||||
import {getSchema} from './schema.js';
|
||||
|
||||
// `@sidvind/better-ajv-errors` has no `"type"` field in its own package.json, so TS's
|
||||
// nodenext resolution infers its extensionless `typings.d.ts` as CJS-format and types the
|
||||
// default import as the whole module namespace rather than the actual default export.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
type BetterAjvErrorsFn = (typeof import('@sidvind/better-ajv-errors'))['default'];
|
||||
const betterAjvErrors = betterAjvErrorsImport as unknown as BetterAjvErrorsFn;
|
||||
|
||||
/**
|
||||
* The string should be a raw JSON string.
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
export * from './cli-args';
|
||||
export * from './cli-args-guards';
|
||||
export * from './format-errors';
|
||||
export * from './schema';
|
||||
export type {ArgumentDefinitions} from './cli-args.js';
|
||||
export {toParserArgs} from './cli-args.js';
|
||||
export {
|
||||
isDriverCommandArgs,
|
||||
isExtensionCommandArgs,
|
||||
isPluginCommandArgs,
|
||||
isServerCommandArgs,
|
||||
isSetupCommandArgs,
|
||||
} from './cli-args-guards.js';
|
||||
export type {FormatConfigErrorsOptions, RawJson} from './format-errors.js';
|
||||
export {formatErrors} from './format-errors.js';
|
||||
export {
|
||||
ALLOWED_SCHEMA_EXTENSIONS,
|
||||
finalizeSchema,
|
||||
flattenSchema,
|
||||
getAllArgSpecs,
|
||||
getArgSpec,
|
||||
getDefaultsForExtension,
|
||||
getDefaultsForSchema,
|
||||
getSchema,
|
||||
hasArgSpec,
|
||||
isAllowedSchemaFileExtension,
|
||||
isFinalized,
|
||||
registerSchema,
|
||||
resetSchema,
|
||||
RoachHotelMap,
|
||||
SchemaFinalizationError,
|
||||
SchemaNameConflictError,
|
||||
SchemaUnknownSchemaError,
|
||||
SchemaUnsupportedSchemaError,
|
||||
validate,
|
||||
} from './schema.js';
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import type {KeywordDefinition} from 'ajv';
|
||||
|
||||
import {transformers} from './cli-transformers';
|
||||
|
||||
export type AppiumCliTransformerName = keyof typeof transformers;
|
||||
|
||||
export interface AppiumJSONSchemaKeywords {
|
||||
appiumCliDest?: string;
|
||||
appiumCliDescription?: string;
|
||||
appiumCliAliases?: string[];
|
||||
appiumCliIgnored?: boolean;
|
||||
appiumCliTransformer?: AppiumCliTransformerName;
|
||||
appiumDeprecated?: boolean;
|
||||
}
|
||||
import type {KeywordDefinition} from './ajv.js';
|
||||
import {transformers} from './cli-transformers.js';
|
||||
import type {AppiumCliTransformerName} from './cli-transformers.js';
|
||||
|
||||
/**
|
||||
* Collection of keyword definitions to add to the singleton `Ajv` instance.
|
||||
|
||||
@@ -3,13 +3,13 @@ import path from 'node:path';
|
||||
import {AppiumConfigJsonSchema} from '@appium/schema';
|
||||
import {util} from '@appium/support';
|
||||
import type {ExtensionType} from '@appium/types';
|
||||
import Ajv, {type ErrorObject, type SchemaObject, type ValidateFunction} from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
|
||||
import {DRIVER_TYPE, PLUGIN_TYPE} from '../constants';
|
||||
import {bindAll, kebabCase, omitKeys, setPath} from '../utils';
|
||||
import {APPIUM_CONFIG_SCHEMA_ID, ArgSpec, SERVER_PROP_NAME} from './arg-spec';
|
||||
import {keywords} from './keywords';
|
||||
import {DRIVER_TYPE, PLUGIN_TYPE} from '../constants.js';
|
||||
import {bindAll, kebabCase, omitKeys, setPath} from '../utils/index.js';
|
||||
import {addFormats, Ajv} from './ajv.js';
|
||||
import type {AjvInstance, ErrorObject, SchemaObject, ValidateFunction} from './ajv.js';
|
||||
import {APPIUM_CONFIG_SCHEMA_ID, ArgSpec, SERVER_PROP_NAME} from './arg-spec.js';
|
||||
import {keywords} from './keywords.js';
|
||||
|
||||
type StrictSchemaObject = SchemaObject & {additionalProperties: false};
|
||||
type FlattenedSchema = {schema: SchemaObject; argSpec: ArgSpec}[];
|
||||
@@ -53,7 +53,7 @@ class AppiumSchema {
|
||||
[DRIVER_TYPE]: new Map(),
|
||||
[PLUGIN_TYPE]: new Map(),
|
||||
};
|
||||
#ajv: Ajv;
|
||||
#ajv: AjvInstance;
|
||||
#finalizedSchemas: Record<string, StrictSchemaObject> | null = null;
|
||||
|
||||
private constructor() {
|
||||
@@ -103,7 +103,7 @@ class AppiumSchema {
|
||||
/**
|
||||
* Configures and creates an Ajv instance.
|
||||
*/
|
||||
private static _instantiateAjv(): Ajv {
|
||||
private static _instantiateAjv(): AjvInstance {
|
||||
const ajv = addFormats(
|
||||
new Ajv({
|
||||
// without this not much validation actually happens
|
||||
@@ -169,16 +169,23 @@ class AppiumSchema {
|
||||
>) {
|
||||
for (const [extName, schema] of extensionSchemas.entries()) {
|
||||
const $ref = ArgSpec.toSchemaBaseRef(extType, extName);
|
||||
(schema as any).$id = $ref;
|
||||
(schema as any).additionalProperties = false;
|
||||
// Clone rather than mutate the registered schema in place: extensions whose schema is
|
||||
// freshly re-parsed on every manifest reload (an inline object, as opposed to one
|
||||
// `import()`ed from a file, which Node's module cache returns by the same reference every
|
||||
// time) would otherwise stop deep-equaling their own registered copy on a later
|
||||
// `registerSchema()` call, once this injects `$id`/`additionalProperties` -- causing a
|
||||
// false "conflicts with an existing schema" error and dropping the extension entirely.
|
||||
const finalizedSchema = structuredClone(schema) as StrictSchemaObject;
|
||||
(finalizedSchema as any).$id = $ref;
|
||||
(finalizedSchema as any).additionalProperties = false;
|
||||
baseSchema.properties.server.properties[extType].properties[extName] = {
|
||||
$ref,
|
||||
$comment: extName,
|
||||
};
|
||||
await ajv.validateSchema(schema, true);
|
||||
addArgSpecs((schema as any).properties, extType, extName);
|
||||
ajv.addSchema(schema, $ref);
|
||||
finalizedSchemas[$ref] = schema as StrictSchemaObject;
|
||||
await ajv.validateSchema(finalizedSchema, true);
|
||||
addArgSpecs((finalizedSchema as any).properties, extType, extName);
|
||||
ajv.addSchema(finalizedSchema, $ref);
|
||||
finalizedSchemas[$ref] = finalizedSchema;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
import {homedir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {fs, util} from '@appium/support';
|
||||
import * as semver from 'semver';
|
||||
|
||||
import {type NormalizedPackageJson, readPackage} from './read-package.js';
|
||||
|
||||
/**
|
||||
* Path to the default `APPIUM_HOME` dir (`~/.appium`).
|
||||
*/
|
||||
export const DEFAULT_APPIUM_HOME: string = path.resolve(homedir(), '.appium');
|
||||
|
||||
/**
|
||||
* Basename of extension manifest file.
|
||||
*/
|
||||
export const MANIFEST_BASENAME = 'extensions.yaml';
|
||||
|
||||
/**
|
||||
* Relative path to extension manifest file from `APPIUM_HOME`.
|
||||
*/
|
||||
export const MANIFEST_RELATIVE_PATH = path.join('node_modules', '.cache', 'appium', MANIFEST_BASENAME);
|
||||
|
||||
/**
|
||||
* Resolves `true` if an `appium` dependency can be found somewhere in the given `cwd`.
|
||||
*/
|
||||
export async function hasAppiumDependency(cwd: string): Promise<boolean> {
|
||||
return Boolean(await findAppiumDependencyPackage(cwd));
|
||||
}
|
||||
|
||||
/**
|
||||
* Given `cwd`, use `npm` to find the closest package _or workspace root_, and return the path if the root depends upon `appium`.
|
||||
*
|
||||
* Looks at `dependencies` and `devDependencies` for `appium`.
|
||||
*/
|
||||
export const findAppiumDependencyPackage = util.memoize(async function findAppiumDependencyPackage(
|
||||
cwd: string = process.cwd(),
|
||||
acceptableVersionRange: string | semver.Range = '>=2.0.0-beta',
|
||||
): Promise<string | undefined> {
|
||||
const readPkg = async (root: string): Promise<string | undefined> => {
|
||||
let pkg: NormalizedPackageJson | undefined;
|
||||
try {
|
||||
pkg = await readPackageInDir(root);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!pkg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const version = semver.minVersion(
|
||||
String(pkg.dependencies?.appium ?? pkg.devDependencies?.appium ?? pkg.peerDependencies?.appium),
|
||||
);
|
||||
return version && semver.satisfies(version, acceptableVersionRange) ? root : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
let currentDir = path.resolve(cwd);
|
||||
let isAtFsRoot = false;
|
||||
while (!isAtFsRoot) {
|
||||
const result = await readPkg(currentDir);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
currentDir = path.dirname(currentDir);
|
||||
isAtFsRoot = currentDir.length <= path.dirname(currentDir).length;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* Read a `package.json` in dir `cwd`. If none is found, resolves with `undefined`.
|
||||
* @returns Parsed package data, or `undefined` when `package.json` is missing in `cwd`
|
||||
*/
|
||||
export const readPackageInDir = util.memoize(async function _readPackageInDir(
|
||||
cwd: string,
|
||||
): Promise<NormalizedPackageJson | undefined> {
|
||||
try {
|
||||
return await readPackage({cwd, normalize: true});
|
||||
} catch (err) {
|
||||
if (isMissingPackageJsonError(err)) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
function isMissingPackageJsonError(err: unknown): boolean {
|
||||
return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines location of Appium's "home" dir
|
||||
*
|
||||
* - If `APPIUM_HOME` is set in the environment, use that
|
||||
* - If we find a `package.json` in or above `cwd` and it has an `appium` dependency, use that.
|
||||
*
|
||||
* All returned paths will be absolute.
|
||||
*/
|
||||
export const resolveAppiumHome = util.memoize(async function _resolveAppiumHome(
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<string> {
|
||||
if (!path.isAbsolute(cwd)) {
|
||||
throw new TypeError('`cwd` parameter must be an absolute path');
|
||||
}
|
||||
|
||||
if (process.env.APPIUM_HOME) {
|
||||
return path.resolve(cwd, process.env.APPIUM_HOME);
|
||||
}
|
||||
|
||||
return (await findAppiumDependencyPackage(cwd)) ?? DEFAULT_APPIUM_HOME;
|
||||
});
|
||||
|
||||
/**
|
||||
* Figure out manifest path based on `appiumHome`.
|
||||
*
|
||||
* The assumption is that, if `appiumHome` has been provided, it was resolved via {@link resolveAppiumHome `resolveAppiumHome()`}! If unsure,
|
||||
* don't pass a parameter and let `resolveAppiumHome()` handle it.
|
||||
*/
|
||||
export const resolveManifestPath = util.memoize(async function _resolveManifestPath(
|
||||
appiumHome?: string,
|
||||
): Promise<string> {
|
||||
return path.join(appiumHome ?? (await resolveAppiumHome()), MANIFEST_RELATIVE_PATH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Relative path, from the user's home dir, to the directory holding manifest lockfiles.
|
||||
*
|
||||
* Deliberately `.cache/appium/locks`, NOT under {@link DEFAULT_APPIUM_HOME} (`~/.appium`): a
|
||||
* custom `APPIUM_HOME` is common, but the *default* one lives at exactly that path, so rooting
|
||||
* locks under it would reintroduce the read-only-`APPIUM_HOME` failure this is meant to avoid
|
||||
* whenever a preinstalled default home is made read-only as a whole.
|
||||
*/
|
||||
const MANIFEST_LOCKS_DIRNAME = path.join('.cache', 'appium', 'locks');
|
||||
|
||||
/**
|
||||
* Figure out the extension manifest lockfile path for `appiumHome`.
|
||||
*
|
||||
* Deliberately lives in a fixed location under the user's home dir (see
|
||||
* {@link MANIFEST_LOCKS_DIRNAME}), keyed by a hash of `appiumHome`'s canonicalized
|
||||
* (symlink-resolved) real path, rather than:
|
||||
* - alongside the manifest itself: a directory being unwritable doesn't imply the manifest
|
||||
* *file* inside it is (directory permissions gate creating/removing entries, not overwriting
|
||||
* an existing one), so a lock colocated with the manifest couldn't be relied on to actually be
|
||||
* creatable exactly when protection is needed most -- see the read-only `APPIUM_HOME` case.
|
||||
* - the OS temp dir: its value depends on `TMPDIR`/platform/user defaults, which can differ
|
||||
* between two processes protecting the very same `appiumHome`, silently defeating locking.
|
||||
* - the raw (non-canonicalized) `appiumHome` string: a symlink alias and its real target
|
||||
* otherwise hash to different lock identities despite being the same location on disk.
|
||||
*
|
||||
* This targets same-user coordination (concurrent CLI/server processes for one user); a single
|
||||
* `APPIUM_HOME` deliberately shared across *different* users' processes would need its lock
|
||||
* pointed at a location those users jointly have write access to, which is out of scope here.
|
||||
*
|
||||
* See caveat on {@link resolveManifestPath} about pre-resolving `appiumHome`.
|
||||
*/
|
||||
export const resolveManifestLockfilePath = util.memoize(async function _resolveManifestLockfilePath(
|
||||
appiumHome?: string,
|
||||
): Promise<string> {
|
||||
const rawHome = appiumHome ?? (await resolveAppiumHome());
|
||||
const resolvedHome = await fs.realpath(rawHome).catch(() => path.resolve(rawHome));
|
||||
const homeHash = createHash('sha256').update(resolvedHome).digest('hex').slice(0, 32);
|
||||
return path.join(homedir(), MANIFEST_LOCKS_DIRNAME, `${homeHash}.lock`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs `behavior` under the cross-process lock guarding the extension manifest for `appiumHome`,
|
||||
* so callers can't race each other's manifest reads/writes. Covers the *entire* read-modify-write
|
||||
* cycle a caller needs protected -- e.g. a manifest reload plus whatever depends on it staying
|
||||
* current, such as re-validating extension configs or writing the manifest back out.
|
||||
*/
|
||||
export async function withManifestLock<T>(appiumHome: string, behavior: () => Promise<T> | T): Promise<T> {
|
||||
const lockFile = await resolveManifestLockfilePath(appiumHome);
|
||||
// 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);
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
export {adler32} from './hash';
|
||||
export {isPackageChanged} from './is-package-changed';
|
||||
export type {IsPackageChangedOptions, IsPackageChangedResult} from './is-package-changed';
|
||||
export {
|
||||
DEFAULT_APPIUM_HOME,
|
||||
MANIFEST_BASENAME,
|
||||
MANIFEST_RELATIVE_PATH,
|
||||
findAppiumDependencyPackage,
|
||||
hasAppiumDependency,
|
||||
resolveAppiumHome,
|
||||
resolveManifestLockfilePath,
|
||||
resolveManifestPath,
|
||||
withManifestLock,
|
||||
} from './env.js';
|
||||
export {adler32} from './hash.js';
|
||||
export {isPackageChanged} from './is-package-changed.js';
|
||||
export type {IsPackageChangedOptions, IsPackageChangedResult} from './is-package-changed.js';
|
||||
export {npm, resolveFrom} from './npm.js';
|
||||
export type {ExecOpts, InstallPackageOpts, NpmExecResult, NpmInstallReceipt} from './npm.js';
|
||||
export {
|
||||
bindAll,
|
||||
camelCase,
|
||||
@@ -17,7 +30,6 @@ export {
|
||||
pull,
|
||||
setPath,
|
||||
zip,
|
||||
} from './object';
|
||||
export {packageDidChange} from './package-changed';
|
||||
export {appiumPackageRoot, npmPackage} from './package-json';
|
||||
export {resolveFrom} from './resolve-from';
|
||||
} from './object.js';
|
||||
export {packageDidChange} from './package-changed.js';
|
||||
export {appiumPackageRoot, npmPackage} from './package-json.js';
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import Module from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
import {fs, system, util} from '@appium/support';
|
||||
import type {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 {hasAppiumDependency} from './env';
|
||||
import {fs} from './fs';
|
||||
import * as system from './system';
|
||||
import * as util from './util';
|
||||
/**
|
||||
* Relative path to directory containing any Appium internal files
|
||||
* XXX: this is duplicated in `appium/lib/constants.js`.
|
||||
*/
|
||||
export const CACHE_DIR_RELATIVE_PATH = path.join('node_modules', '.cache', 'appium');
|
||||
|
||||
/**
|
||||
* Relative path to lockfile used when installing an extension via `appium`
|
||||
*/
|
||||
export const INSTALL_LOCKFILE_RELATIVE_PATH = path.join(CACHE_DIR_RELATIVE_PATH, '.install.lock');
|
||||
import {INSTALL_LOCKFILE_RELATIVE_PATH} from '../constants.js';
|
||||
|
||||
/** Options for {@link NPM.exec} */
|
||||
export interface ExecOpts {
|
||||
@@ -38,6 +26,8 @@ export interface InstallPackageOpts {
|
||||
pkgName: string;
|
||||
/** Whether to install from a local path or from npm */
|
||||
installType?: 'local' | string;
|
||||
/** Whether `cwd` (or an ancestor) already declares a dependency on `appium` */
|
||||
hasAppiumDependency: boolean;
|
||||
}
|
||||
|
||||
/** Result of {@link NPM.installPackage} */
|
||||
@@ -198,7 +188,7 @@ export class NPM {
|
||||
* Installs a package w/ `npm`
|
||||
*/
|
||||
async installPackage(cwd: string, installStr: string, opts: InstallPackageOpts): Promise<NpmInstallReceipt> {
|
||||
const {pkgName, installType} = opts;
|
||||
const {pkgName, installType, hasAppiumDependency} = opts;
|
||||
let dummyPkgJson: Record<string, unknown>;
|
||||
const dummyPkgPath = path.join(cwd, 'package.json');
|
||||
try {
|
||||
@@ -214,7 +204,7 @@ export class NPM {
|
||||
}
|
||||
|
||||
const installOpts = ['--save-dev', '--no-progress', '--no-audit'];
|
||||
if (!(await hasAppiumDependency(cwd))) {
|
||||
if (!hasAppiumDependency) {
|
||||
if (process.env.APPIUM_OMIT_PEER_DEPS) {
|
||||
installOpts.push('--omit=peer');
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import path from 'node:path';
|
||||
|
||||
import {fs} from '@appium/support';
|
||||
|
||||
import {PKG_HASHFILE_RELATIVE_PATH} from '../constants';
|
||||
import {log} from '../logger';
|
||||
import {isPackageChanged} from './is-package-changed';
|
||||
import {PKG_HASHFILE_RELATIVE_PATH} from '../constants.js';
|
||||
import {log} from '../logger.js';
|
||||
import {isPackageChanged} from './is-package-changed.js';
|
||||
|
||||
/**
|
||||
* Determines if extensions have changed, and updates a hash the `package.json` in `appiumHome` if so.
|
||||
|
||||
@@ -9,7 +9,7 @@ type AppiumPackageJson = PackageJson & {
|
||||
};
|
||||
|
||||
function readPackageJsonSync(): {pkgRoot: string; pkg: AppiumPackageJson} {
|
||||
let current = path.resolve(__dirname);
|
||||
let current = path.resolve(import.meta.dirname);
|
||||
const root = path.parse(current).root;
|
||||
let pkgRoot: string;
|
||||
while (true) {
|
||||
@@ -18,13 +18,13 @@ function readPackageJsonSync(): {pkgRoot: string; pkg: AppiumPackageJson} {
|
||||
break;
|
||||
}
|
||||
if (current === root) {
|
||||
throw new Error(`Could not find \`package.json\` from ${__dirname}`);
|
||||
throw new Error(`Could not find \`package.json\` from ${import.meta.dirname}`);
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
const pkg = JSON.parse(nodeFs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')) as PackageJson;
|
||||
if (typeof pkg.name !== 'string' || typeof pkg.version !== 'string') {
|
||||
throw new Error(`Invalid \`package.json\` near ${__dirname}`);
|
||||
throw new Error(`Invalid \`package.json\` near ${import.meta.dirname}`);
|
||||
}
|
||||
return {pkgRoot, pkg: pkg as AppiumPackageJson};
|
||||
}
|
||||
|
||||
-31
@@ -1,4 +1,3 @@
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -23,36 +22,6 @@ export type ReadPackageOptions = {
|
||||
|
||||
export type NormalizeOptions = ReadPackageOptions & {normalize?: true};
|
||||
|
||||
export type PackageDirectoryOptions = {
|
||||
/** Directory to search upward from. Defaults to `process.cwd()`. */
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
/** Finds the directory containing the nearest `package.json` by walking upward from `cwd`. */
|
||||
export function packageDirectorySync({cwd}: PackageDirectoryOptions = {}): string | undefined {
|
||||
let dir = path.resolve(cwd ?? process.cwd());
|
||||
const fsRoot = path.parse(dir).root;
|
||||
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) {
|
||||
return dir;
|
||||
}
|
||||
if (dir === fsRoot) {
|
||||
return undefined;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads and parses `package.json` from `cwd`. */
|
||||
export function readPackageSync(options?: NormalizeOptions): NormalizedPackageJson;
|
||||
export function readPackageSync(options: ReadPackageOptions): PackageJson;
|
||||
export function readPackageSync(options: ReadPackageOptions = {}): PackageJson | NormalizedPackageJson {
|
||||
const {cwd, normalize = true} = options;
|
||||
const contents = fs.readFileSync(getPackagePath(cwd), 'utf8');
|
||||
return parsePackageJson(contents, normalize);
|
||||
}
|
||||
|
||||
/** Reads and parses `package.json` from `cwd`. */
|
||||
export async function readPackage(options?: NormalizeOptions): Promise<NormalizedPackageJson>;
|
||||
export async function readPackage(options: ReadPackageOptions): Promise<PackageJson>;
|
||||
@@ -1,37 +0,0 @@
|
||||
import {realpath} from 'node:fs/promises';
|
||||
import Module from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Resolves `moduleId` using Node's module resolution from `fromDirectory`.
|
||||
*
|
||||
* @param fromDirectory - Directory to resolve from (typically a project or `APPIUM_HOME` root)
|
||||
* @param moduleId - Module id or path to resolve (e.g. `semver/package.json`)
|
||||
* @returns Resolved module id. Package paths are typically absolute filesystem paths; built-in
|
||||
* modules may resolve to non-absolute ids (e.g. `node:fs`, `fs`).
|
||||
* @throws `Error` if Node cannot resolve `moduleId` from `fromDirectory`
|
||||
*/
|
||||
export async function resolveFrom(fromDirectory: string, moduleId: string): Promise<string> {
|
||||
let resolvedFromDirectory: string;
|
||||
try {
|
||||
resolvedFromDirectory = await realpath(fromDirectory);
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === 'ENOENT') {
|
||||
resolvedFromDirectory = path.resolve(fromDirectory);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const fromFile = path.join(resolvedFromDirectory, 'noop.js');
|
||||
const nodeModule = Module as typeof Module & {
|
||||
_resolveFilename: (id: string, parent: {id: string; filename: string; paths: string[]}) => string;
|
||||
_nodeModulePaths: (from: string) => string[];
|
||||
};
|
||||
return nodeModule._resolveFilename(moduleId, {
|
||||
id: fromFile,
|
||||
filename: fromFile,
|
||||
paths: nodeModule._nodeModulePaths(resolvedFromDirectory),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "appium",
|
||||
"version": "3.8.0",
|
||||
"version": "4.0.0-beta.1",
|
||||
"gitHead": "f7b20335eab4022e5cbbb627ec86866a994444f8",
|
||||
"description": "Automation for Apps.",
|
||||
"keywords": [
|
||||
@@ -38,10 +38,11 @@
|
||||
"driver.*",
|
||||
"support.*",
|
||||
"plugin.*",
|
||||
"scripts/autoinstall-extensions.js",
|
||||
"types",
|
||||
"tsconfig.json"
|
||||
"tsconfig.json",
|
||||
"!scripts/ci"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./build/lib/main.js",
|
||||
"types": "./build/lib/main.d.ts",
|
||||
"publishConfig": {
|
||||
@@ -57,39 +58,43 @@
|
||||
"dev:docs:ja": "appium-docs build --serve --mkdocs-yml ./docs/mkdocs-ja.yml",
|
||||
"dev:docs:zh": "appium-docs build --serve --mkdocs-yml ./docs/mkdocs-zh.yml",
|
||||
"install-docs-deps": "appium-docs init --no-mkdocs",
|
||||
"postinstall": "node ./scripts/autoinstall-extensions.js",
|
||||
"publish:docs": "cross-env APPIUM_DOCS_PUBLISH=1 npm run build:docs",
|
||||
"test": "npm run test:unit",
|
||||
"test:e2e": "node --test --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:unit": "node --test --enable-source-maps --test-timeout=5000 \"./build/test/unit/**/*.spec.js\""
|
||||
"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 ./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": "10.8.1",
|
||||
"@appium/base-plugin": "3.3.5",
|
||||
"@appium/docutils": "3.0.1",
|
||||
"@appium/logger": "2.0.12",
|
||||
"@appium/schema": "1.3.0",
|
||||
"@appium/support": "7.2.8",
|
||||
"@appium/types": "1.7.1",
|
||||
"@sidvind/better-ajv-errors": "5.0.0",
|
||||
"@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",
|
||||
"@sidvind/better-ajv-errors": "7.0.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"argparse": "3.0.2",
|
||||
"asyncbox": "6.4.3",
|
||||
"axios": "1.20.0",
|
||||
"argparse": "3.0.0",
|
||||
"asyncbox": "6.4.2",
|
||||
"axios": "1.19.0",
|
||||
"lilconfig": "3.1.3",
|
||||
"lru-cache": "11.5.3",
|
||||
"lru-cache": "11.5.2",
|
||||
"normalize-package-data": "8.0.0",
|
||||
"ora": "5.4.1",
|
||||
"semver": "7.8.5",
|
||||
"teen_process": "4.2.1",
|
||||
"type-fest": "5.10.0",
|
||||
"type-fest": "5.8.0",
|
||||
"winston": "3.19.0",
|
||||
"ws": "8.21.3",
|
||||
"yaml": "2.9.1"
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/json-schema": "7.0.15",
|
||||
"winston-transport": "4.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0",
|
||||
"npm": ">=10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
@@ -7,7 +5,6 @@
|
||||
*
|
||||
* @see https://npm.im/@appium/base-plugin
|
||||
* @example
|
||||
* const { BasePlugin } = require('appium/plugin');
|
||||
* import { BasePlugin } from 'appium/plugin.js';
|
||||
*/
|
||||
|
||||
module.exports = require('@appium/base-plugin');
|
||||
export * from '@appium/base-plugin';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
module.exports = {
|
||||
server: {
|
||||
address: '127.0.0.1',
|
||||
'allow-cors': true,
|
||||
'allow-insecure': ['*:foo', '*:bar'],
|
||||
'base-path': '/',
|
||||
'callback-address': '127.0.0.1',
|
||||
@@ -24,9 +23,6 @@ module.exports = {
|
||||
'log-timestamp': true,
|
||||
'long-stacktrace': false,
|
||||
'no-perms-check': false,
|
||||
nodeconfig: {
|
||||
key: 'value',
|
||||
},
|
||||
plugin: {
|
||||
images: {
|
||||
key: 'value',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"server": {
|
||||
"address": "127.0.0.1",
|
||||
"allow-cors": true,
|
||||
"allow-insecure": ["*:foo", "*:bar"],
|
||||
"base-path": "/",
|
||||
"callback-address": "127.0.0.1",
|
||||
@@ -24,9 +23,6 @@
|
||||
"log-timestamp": true,
|
||||
"long-stacktrace": false,
|
||||
"no-perms-check": false,
|
||||
"nodeconfig": {
|
||||
"key": "value"
|
||||
},
|
||||
"plugin": {
|
||||
"images": {
|
||||
"key": "value"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
server:
|
||||
address: 127.0.0.1
|
||||
allow-cors: true
|
||||
allow-insecure:
|
||||
- foo
|
||||
- bar
|
||||
@@ -24,8 +23,6 @@ server:
|
||||
log-timestamp: true
|
||||
long-stacktrace: false
|
||||
no-perms-check: false
|
||||
nodeconfig:
|
||||
key: value
|
||||
plugin:
|
||||
images:
|
||||
key: value
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"webdriverio": "9.31.9"
|
||||
"webdriverio": "9.31.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// @ts-check
|
||||
|
||||
/* eslint-disable no-console, promise/prefer-await-to-then */
|
||||
|
||||
/**
|
||||
* This script is intended to be run as a `postinstall` lifecycle script,
|
||||
* and will automatically install extensions if requested by the user.
|
||||
*
|
||||
* If the current working directory is within a project which has `appium`
|
||||
* as a dependency, this script does nothing; extensions must be managed
|
||||
* via `npm` or another package manager.
|
||||
*
|
||||
* If `CI=1` is in the environment, this script will exit with a non-zero
|
||||
* code upon failure (which will typically break a build). Otherwise, it
|
||||
* will always exit with code 0, even if errors occur.
|
||||
*
|
||||
* @module
|
||||
* @example
|
||||
* `npm install -g appium --drivers=uiautomator2,xcuitest --plugins=images`
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
const {realpath} = require('node:fs/promises');
|
||||
|
||||
/** @type {typeof import('../lib/cli/extension').runExtensionCommand} */
|
||||
let runExtensionCommand;
|
||||
/** @type {typeof import('../lib/constants').DRIVER_TYPE} */
|
||||
let DRIVER_TYPE;
|
||||
/** @type {typeof import('../lib/constants').PLUGIN_TYPE} */
|
||||
let PLUGIN_TYPE;
|
||||
/** @type {typeof import('../lib/extension').loadExtensions} */
|
||||
let loadExtensions;
|
||||
|
||||
const ora = require('ora');
|
||||
|
||||
/** @type {typeof import('@appium/support').env} */
|
||||
let env;
|
||||
/** @type {typeof import('@appium/support').util} */
|
||||
let util;
|
||||
/** @type {typeof import('@appium/support').logger} */
|
||||
let logger;
|
||||
|
||||
function log(message) {
|
||||
console.error(`[Appium] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a naive attempt at determining whether or not we are in a dev environment; in other
|
||||
* words, is `postinstall` being run from within the `appium` monorepo?
|
||||
*
|
||||
* When we're in the monorepo, `npm_config_local_prefix` will be set to the root of the monorepo root
|
||||
* dir when running this lifecycle script from an `npm install` in the monorepo root.
|
||||
*
|
||||
* `realpath` is necessary due to macOS omitting `/private` from paths
|
||||
*/
|
||||
async function isDevEnvironment() {
|
||||
return (
|
||||
process.env.npm_config_local_prefix &&
|
||||
path.join(process.env.npm_config_local_prefix, 'packages', 'appium') ===
|
||||
(await realpath(path.join(__dirname, '..')))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup / check environment if we should do anything here
|
||||
* @returns {Promise<boolean>} `true` if Appium is built and ready to go
|
||||
*/
|
||||
async function init() {
|
||||
if (await isDevEnvironment()) {
|
||||
log('Dev environment likely; skipping automatic installation of extensions');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
({env, util, logger} = require('@appium/support'));
|
||||
// @ts-ignore This is OK
|
||||
({runExtensionCommand} = require('../build/lib/cli/extension'));
|
||||
({DRIVER_TYPE, PLUGIN_TYPE} = require('../build/lib/constants'));
|
||||
// @ts-ignore This is OK
|
||||
({loadExtensions} = require('../build/lib/extension'));
|
||||
logger.getLogger('Appium').level = 'error';
|
||||
|
||||
// if we're doing `npm install -g appium` then we will assume we don't have a local appium.
|
||||
if (!process.env.npm_config_global && (await env.hasAppiumDependency(process.cwd()))) {
|
||||
log(`Found local Appium installation; skipping automatic installation of extensions.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
log('Dev environment likely; skipping automatic installation of extensions');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs drivers/plugins requested via npm config vars.
|
||||
*/
|
||||
async function main() {
|
||||
if (!(await init())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const driverEnv = process.env.npm_config_drivers;
|
||||
const pluginEnv = process.env.npm_config_plugins;
|
||||
|
||||
const spinner = ora({
|
||||
text: 'Looking for extensions to automatically install...',
|
||||
prefixText: '[Appium]',
|
||||
}).start();
|
||||
|
||||
if (!driverEnv && !pluginEnv) {
|
||||
spinner.succeed('No drivers or plugins to automatically install.');
|
||||
log(
|
||||
'If desired, provide the argument "--drivers=<driver_name>[,<driver_name>...]" and/or ' +
|
||||
'"--plugins=<plugin_name>[,<plugin_name>...]" to the "npm install appium" command. ' +
|
||||
'The specified extensions will be installed automatically alongside Appium. ' +
|
||||
'For a list of known extensions, run "appium <driver|plugin> list".',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {[[typeof DRIVER_TYPE, string?], [typeof PLUGIN_TYPE, string?]]}
|
||||
*/
|
||||
const specs = [
|
||||
[DRIVER_TYPE, driverEnv],
|
||||
[PLUGIN_TYPE, pluginEnv],
|
||||
];
|
||||
|
||||
spinner.start('Resolving Appium home directory...');
|
||||
const appiumHome = await env.resolveAppiumHome();
|
||||
spinner.succeed(`Found Appium home: ${appiumHome}`);
|
||||
|
||||
spinner.start('Loading extension data...');
|
||||
const {driverConfig, pluginConfig} = await loadExtensions(appiumHome);
|
||||
spinner.succeed('Loaded extension data.');
|
||||
|
||||
const installedStats = {[DRIVER_TYPE]: 0, [PLUGIN_TYPE]: 0};
|
||||
for (const [type, extEnv] of specs) {
|
||||
if (extEnv) {
|
||||
for await (let ext of extEnv.split(',')) {
|
||||
ext = ext.trim();
|
||||
try {
|
||||
await checkAndInstallExtension({
|
||||
runExtensionCommand,
|
||||
appiumHome,
|
||||
type,
|
||||
ext,
|
||||
driverConfig,
|
||||
pluginConfig,
|
||||
spinner,
|
||||
});
|
||||
installedStats[type]++;
|
||||
} catch (e) {
|
||||
spinner.fail(`Could not install ${type} "${ext}": ${e.message}`);
|
||||
if (process.env.CI) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
spinner.succeed(
|
||||
`Done. ${installedStats[DRIVER_TYPE]} ${util.pluralize(
|
||||
'driver',
|
||||
installedStats[DRIVER_TYPE],
|
||||
)} and ${installedStats[PLUGIN_TYPE]} ${util.pluralize('plugin', installedStats[PLUGIN_TYPE])} are installed.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @privateRemarks the two `@ts-ignore` directives here are because I have no idea what's wrong with
|
||||
* the types and don't want to spend more time on it. regardless, it seems to work for now.
|
||||
* @param {CheckAndInstallExtensionsOpts} opts
|
||||
*/
|
||||
async function checkAndInstallExtension({
|
||||
runExtensionCommand,
|
||||
appiumHome,
|
||||
type,
|
||||
ext,
|
||||
driverConfig,
|
||||
pluginConfig,
|
||||
spinner,
|
||||
}) {
|
||||
const extList = await runExtensionCommand(
|
||||
// @ts-ignore This is OK
|
||||
{
|
||||
appiumHome,
|
||||
subcommand: type,
|
||||
[`${type}Command`]: 'list',
|
||||
showInstalled: true,
|
||||
suppressOutput: true,
|
||||
},
|
||||
type === DRIVER_TYPE ? driverConfig : pluginConfig,
|
||||
);
|
||||
if (extList[ext]) {
|
||||
spinner.info(`The ${type} "${ext}" is already installed.`);
|
||||
return;
|
||||
}
|
||||
spinner.start(`Installing ${type} "${ext}"...`);
|
||||
await runExtensionCommand(
|
||||
// @ts-ignore this is OK
|
||||
{
|
||||
subcommand: type,
|
||||
appiumHome,
|
||||
[`${type}Command`]: 'install',
|
||||
suppressOutput: true,
|
||||
[type]: ext,
|
||||
},
|
||||
type === DRIVER_TYPE ? driverConfig : pluginConfig,
|
||||
);
|
||||
spinner.succeed(`Installed ${type} "${ext}".`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((e) => {
|
||||
log(e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = main;
|
||||
|
||||
/**
|
||||
* @typedef CheckAndInstallExtensionsOpts
|
||||
* @property {typeof runExtensionCommand} runExtensionCommand
|
||||
* @property {string} appiumHome
|
||||
* @property {DRIVER_TYPE | PLUGIN_TYPE} type
|
||||
* @property {string} ext
|
||||
* @property {import('../lib/extension/driver-config').DriverConfig} driverConfig
|
||||
* @property {import('../lib/extension/plugin-config').PluginConfig} pluginConfig
|
||||
* @property {import('ora').Ora} spinner
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -1 +1,2 @@
|
||||
export * from '@appium/support';
|
||||
export {default} from '@appium/support';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
@@ -7,7 +5,7 @@
|
||||
*
|
||||
* @see https://npm.im/@appium/support
|
||||
* @example
|
||||
* const { fs, npm } = require('appium/support');
|
||||
* import { fs, system } from 'appium/support.js';
|
||||
*/
|
||||
|
||||
module.exports = require('@appium/support');
|
||||
export * from '@appium/support';
|
||||
export {default} from '@appium/support';
|
||||
|
||||
@@ -4,9 +4,9 @@ import {describe, it, before, after} from 'node:test';
|
||||
import {fs, tempDir} from '@appium/support';
|
||||
import {exec} from 'teen_process';
|
||||
|
||||
import {stripColorCodes} from '../../lib/logsink';
|
||||
import {APPIUM_ROOT, getTestPort} from '../helpers';
|
||||
import {EXECUTABLE, formatAppiumArgErrorOutput, readAppiumArgErrorFixture, runAppiumRaw} from './e2e-helpers';
|
||||
import {stripColorCodes} from '../../lib/logsink.js';
|
||||
import {APPIUM_ROOT, getTestPort} from '../helpers.js';
|
||||
import {EXECUTABLE, formatAppiumArgErrorOutput, readAppiumArgErrorFixture, runAppiumRaw} from './e2e-helpers.js';
|
||||
|
||||
describe('argument parsing', function () {
|
||||
let appiumHome: string;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtemp, readFile, rm, symlink} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {after, before, describe, it} from 'node:test';
|
||||
|
||||
import {exec} from 'teen_process';
|
||||
|
||||
import {APPIUM_ROOT} from '../helpers.js';
|
||||
|
||||
// `process.argv[1]` retains the invoked path as-is on Unix (the bin symlink npm creates for a
|
||||
// global install), while `import.meta.url`'s resolved value follows the symlink to the real
|
||||
// file — a naive `process.argv[1] === fileURLToPath(import.meta.url)` "am I the entry module"
|
||||
// check is therefore always false when launched this way, and `main()` is silently never called.
|
||||
describe('bin symlink invocation', {skip: process.platform === 'win32'}, function () {
|
||||
let symlinkDir: string;
|
||||
let binSymlink: string;
|
||||
|
||||
before(async function () {
|
||||
symlinkDir = await mkdtemp(path.join(tmpdir(), 'appium-bin-symlink-'));
|
||||
binSymlink = path.join(symlinkDir, 'appium');
|
||||
await symlink(path.join(APPIUM_ROOT, 'index.js'), binSymlink);
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
await rm(symlinkDir, {recursive: true, force: true});
|
||||
});
|
||||
|
||||
it('should run main() when invoked through a bin-style symlink', async function () {
|
||||
const {stdout} = await exec(process.execPath, [binSymlink, '--version']);
|
||||
const {version} = JSON.parse(await readFile(path.join(APPIUM_ROOT, 'package.json'), 'utf8'));
|
||||
assert.strictEqual(stdout.trim(), version);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@ 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';
|
||||
import {prerelease} from 'semver';
|
||||
import type {ExtRecord} from 'appium/types/index.js';
|
||||
import * as semver from 'semver';
|
||||
import {exec} from 'teen_process';
|
||||
|
||||
import {
|
||||
@@ -16,13 +16,52 @@ import {
|
||||
EXT_SUBCOMMAND_RUN as RUN,
|
||||
EXT_SUBCOMMAND_UNINSTALL as UNINSTALL,
|
||||
KNOWN_DRIVERS,
|
||||
} from '../../lib/constants';
|
||||
import {omitKeys, resolveFrom} from '../../lib/utils';
|
||||
import {FAKE_DRIVER_DIR, resolveFixture} from '../helpers';
|
||||
import {installLocalExtension, runAppiumJson, runAppiumRaw} from './e2e-helpers';
|
||||
} 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 {
|
||||
@@ -83,9 +122,9 @@ describe('Driver CLI', {timeout: 90000}, function () {
|
||||
|
||||
describe(LIST, function () {
|
||||
it('should list available drivers', async function () {
|
||||
const {stderr} = await runAppiumRaw(appiumHome, [DRIVER_TYPE, LIST], {});
|
||||
const {stdout} = await runAppiumRaw(appiumHome, [DRIVER_TYPE, LIST], {});
|
||||
for (const d of Object.keys(KNOWN_DRIVERS)) {
|
||||
assert.match(stderr, new RegExp(`${d}.+[not installed]`));
|
||||
assert.match(stdout, new RegExp(`${d}.+[not installed]`));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,17 +148,27 @@ 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>,
|
||||
),
|
||||
]);
|
||||
|
||||
// Update checks skip prereleases, so pick from stable versions to guarantee an update exists.
|
||||
const stableVersions = versions.filter((v) => !prerelease(v));
|
||||
const penultimateFakeDriverVersionAsOfRightNow = stableVersions[stableVersions.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`.
|
||||
const referenceVersion = IS_PRERELEASE_SERVER ? distTags.beta : distTags.latest;
|
||||
const sameLineVersions = versions.filter((v) => semver.major(v) === semver.major(referenceVersion));
|
||||
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']);
|
||||
@@ -138,8 +187,8 @@ describe('Driver CLI', {timeout: 90000}, function () {
|
||||
util.compareVersions(String(updateVersion), '>', penultimateFakeDriverVersionAsOfRightNow),
|
||||
true,
|
||||
);
|
||||
const {stderr} = await runAppiumRaw(appiumHome, [DRIVER_TYPE, LIST, '--updates'], {});
|
||||
assert.match(stderr, new RegExp(`fake.+[${updateVersion} available]`));
|
||||
const {stdout} = await runAppiumRaw(appiumHome, [DRIVER_TYPE, LIST, '--updates'], {});
|
||||
assert.match(stdout, new RegExp(`fake.+[${updateVersion} available]`));
|
||||
});
|
||||
|
||||
describe('if a driver is not published to npm', function () {
|
||||
@@ -161,8 +210,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');
|
||||
@@ -175,11 +235,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, {
|
||||
@@ -189,8 +252,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);
|
||||
@@ -199,9 +264,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);
|
||||
@@ -299,9 +373,9 @@ describe('Driver CLI', {timeout: 90000}, function () {
|
||||
[DRIVER_TYPE, INSTALL, '--source', 'local', TEST_DRIVER_INVALID_PEERS_DIR],
|
||||
{},
|
||||
);
|
||||
if ('stderr' in ret) {
|
||||
assert.match(ret.stderr, /may be incompatible with the current version of Appium/i);
|
||||
assert.match(ret.stderr, /successfully installed/i);
|
||||
if ('stdout' in ret) {
|
||||
assert.match(ret.stdout, /may be incompatible with the current version of Appium/i);
|
||||
assert.match(ret.stdout, /successfully installed/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -309,9 +383,9 @@ describe('Driver CLI', {timeout: 90000}, function () {
|
||||
describe('when peer dependencies are valid', function () {
|
||||
it('should not display a warning', async function () {
|
||||
const ret = await runAppiumRaw(appiumHome, [DRIVER_TYPE, INSTALL, '--source', 'local', TEST_DRIVER_DIR], {});
|
||||
if ('stderr' in ret) {
|
||||
assert.doesNotMatch(ret.stderr, /may be incompatible with the current version of Appium/i);
|
||||
assert.match(ret.stderr, /successfully installed/i);
|
||||
if ('stdout' in ret) {
|
||||
assert.doesNotMatch(ret.stdout, /may be incompatible with the current version of Appium/i);
|
||||
assert.match(ret.stdout, /successfully installed/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,9 @@ import {describe, it, before, after} from 'node:test';
|
||||
|
||||
import {fs, tempDir} from '@appium/support';
|
||||
|
||||
import {EXT_SUBCOMMAND_RUN as RUN, PLUGIN_TYPE} from '../../lib/constants';
|
||||
import {FAKE_PLUGIN_DIR} from '../helpers';
|
||||
import {installLocalExtension, runAppiumJson} from './e2e-helpers';
|
||||
import {EXT_SUBCOMMAND_RUN as RUN, PLUGIN_TYPE} from '../../lib/constants.js';
|
||||
import {FAKE_PLUGIN_DIR} from '../helpers.js';
|
||||
import {installLocalExtension, runAppiumJson} from './e2e-helpers.js';
|
||||
|
||||
describe('Plugin CLI', function () {
|
||||
let appiumHome: string;
|
||||
|
||||
@@ -3,17 +3,16 @@ import {describe, it, afterEach, beforeEach, type TestContext} from 'node:test';
|
||||
|
||||
import {system, util} from '@appium/support';
|
||||
|
||||
import {readConfigFile} from '../../lib/bootstrap/config-file';
|
||||
import {DRIVER_TYPE} from '../../lib/constants';
|
||||
import {finalizeSchema, registerSchema, resetSchema} from '../../lib/schema/schema';
|
||||
import extSchema from '../fixtures/driver-schema';
|
||||
import {resolveFixture} from '../helpers';
|
||||
import {readConfigFile} from '../../lib/bootstrap/config-file.js';
|
||||
import {DRIVER_TYPE} from '../../lib/constants.js';
|
||||
import {finalizeSchema, registerSchema, resetSchema} from '../../lib/schema/schema.js';
|
||||
import extSchema from '../fixtures/driver-schema.js';
|
||||
import {resolveFixture} from '../helpers.js';
|
||||
|
||||
const resolveConfigFixture = (name: string) => resolveFixture('config', name);
|
||||
|
||||
describe('config file behavior', function () {
|
||||
const GOOD_FILEPATH = resolveConfigFixture('appium-config-good.json');
|
||||
const BAD_NODECONFIG_FILEPATH = resolveConfigFixture('appium-config-bad-nodeconfig.json');
|
||||
const BAD_FILEPATH = resolveConfigFixture('appium-config-bad.json');
|
||||
const INVALID_JSON_FILEPATH = resolveConfigFixture('appium-config-invalid.json');
|
||||
const SECURITY_ARRAY_FILEPATH = resolveConfigFixture('appium-config-security-array.json');
|
||||
@@ -39,7 +38,6 @@ describe('config file behavior', function () {
|
||||
config: {
|
||||
server: {
|
||||
address: '0.0.0.0',
|
||||
allowCors: false,
|
||||
allowInsecure: [],
|
||||
basePath: '/',
|
||||
callbackAddress: '0.0.0.0',
|
||||
@@ -55,9 +53,6 @@ describe('config file behavior', function () {
|
||||
logTimestamp: false,
|
||||
longStacktrace: false,
|
||||
noPermsCheck: false,
|
||||
nodeconfig: {
|
||||
foo: 'bar',
|
||||
},
|
||||
port: 31337,
|
||||
relaxedSecurityEnabled: true,
|
||||
sessionOverride: false,
|
||||
@@ -74,23 +69,6 @@ describe('config file behavior', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('`server.nodeconfig` behavior', function () {
|
||||
describe('when a string', function () {
|
||||
it('should return errors', async function () {
|
||||
const result = await readConfigFile(BAD_NODECONFIG_FILEPATH);
|
||||
assert.strictEqual(result.errors?.[0]?.instancePath, '/server/nodeconfig');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when an object', function () {
|
||||
it('should return a valid config object', async function () {
|
||||
const result = await readConfigFile(GOOD_FILEPATH);
|
||||
assert.ok(Object.hasOwn(result, 'errors'));
|
||||
assert.strictEqual(result.errors?.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('`server.allow-insecure` behavior', function () {
|
||||
describe('when a string path', function () {
|
||||
it('should return errors', async function () {
|
||||
@@ -151,7 +129,6 @@ describe('config file behavior', function () {
|
||||
appiumHome: 'foo',
|
||||
server: {
|
||||
address: '0.0.0.0',
|
||||
allowCors: 1,
|
||||
allowInsecure: {},
|
||||
basePath: '/',
|
||||
callbackAddress: '0.0.0.0',
|
||||
@@ -167,7 +144,6 @@ describe('config file behavior', function () {
|
||||
logTimestamp: false,
|
||||
longStacktrace: false,
|
||||
noPermsCheck: false,
|
||||
nodeconfig: {},
|
||||
port: '31337',
|
||||
relaxedSecurityEnabled: false,
|
||||
sessionOverride: false,
|
||||
@@ -180,7 +156,7 @@ describe('config file behavior', function () {
|
||||
},
|
||||
});
|
||||
assert.strictEqual(result.filepath, BAD_FILEPATH);
|
||||
assert.strictEqual(result.errors?.length, 7);
|
||||
assert.strictEqual(result.errors?.length, 6);
|
||||
assert.ok(
|
||||
result.errors?.some((error) =>
|
||||
util.isEqual(error, {
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {describe, it, beforeEach, afterEach} from 'node:test';
|
||||
import {describe, it, beforeEach, afterEach, before, after, mock} from 'node:test';
|
||||
|
||||
import axios from 'axios';
|
||||
import {createSandbox, type SinonSandbox} from 'sinon';
|
||||
import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon';
|
||||
import * as teenProcess from 'teen_process';
|
||||
|
||||
import {APPIUM_VER, getBuildInfo, getGitRev, updateBuildInfo} from '../../lib/helpers/build';
|
||||
import type {
|
||||
APPIUM_VER as AppiumVerType,
|
||||
getBuildInfo as GetBuildInfoFn,
|
||||
getGitRev as GetGitRevFn,
|
||||
updateBuildInfo as UpdateBuildInfoFn,
|
||||
} from '../../lib/helpers/build.js';
|
||||
|
||||
describe('Config', function () {
|
||||
let sandbox: SinonSandbox;
|
||||
let execStub: SinonStub;
|
||||
let APPIUM_VER: typeof AppiumVerType;
|
||||
let getBuildInfo: typeof GetBuildInfoFn;
|
||||
let getGitRev: typeof GetGitRevFn;
|
||||
let updateBuildInfo: typeof UpdateBuildInfoFn;
|
||||
|
||||
// `teen_process`'s real ESM namespace is frozen, so sinon can't stub `exec` on it directly;
|
||||
// mock the whole module once with a stub that calls through to the real `exec` by default,
|
||||
// reconfigured (via `sandbox`) per test to simulate a missing local git.
|
||||
before(async function () {
|
||||
execStub = createSandbox()
|
||||
.stub()
|
||||
.callsFake((...args: Parameters<typeof teenProcess.exec>) => teenProcess.exec(...args));
|
||||
mock.module('teen_process', {namedExports: {...teenProcess, exec: execStub}});
|
||||
({APPIUM_VER, getBuildInfo, getGitRev, updateBuildInfo} = await import('../../lib/helpers/build.js'));
|
||||
});
|
||||
|
||||
after(function () {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
sandbox = createSandbox();
|
||||
execStub.resetBehavior();
|
||||
execStub.resetHistory();
|
||||
execStub.callsFake((...args: Parameters<typeof teenProcess.exec>) => teenProcess.exec(...args));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@@ -37,9 +65,9 @@ describe('Config', function () {
|
||||
const buildInfo = getBuildInfo();
|
||||
const {sha, built} = opts;
|
||||
|
||||
const innerExecStub = sandbox.stub().throws();
|
||||
if (!useLocalGit) {
|
||||
sandbox.stub(teenProcess, 'exec').get(() => innerExecStub);
|
||||
execStub.resetBehavior();
|
||||
execStub.throws();
|
||||
}
|
||||
(buildInfo as unknown as Record<string, undefined>)['git-sha'] = undefined;
|
||||
(buildInfo as unknown as Record<string, undefined>).built = undefined;
|
||||
@@ -58,7 +86,7 @@ describe('Config', function () {
|
||||
assert.ok(buildInfo.version);
|
||||
|
||||
if (!useLocalGit) {
|
||||
assert.ok(innerExecStub.callCount >= 1);
|
||||
assert.ok(execStub.callCount >= 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,24 @@ import assert from 'node:assert/strict';
|
||||
import {describe, it, before, after, beforeEach, afterEach} from 'node:test';
|
||||
|
||||
import {BaseDriver} from '@appium/base-driver';
|
||||
import {httpDelete, httpGet, httpPost} from '@appium/driver-test-support';
|
||||
import {fs, tempDir} from '@appium/support';
|
||||
import type {AppiumServer, DriverClass} from '@appium/types';
|
||||
import type {ParsedArgs} from 'appium/types';
|
||||
import type {ParsedArgs} from 'appium/types/index.js';
|
||||
import {sleep} from 'asyncbox';
|
||||
import axios from 'axios';
|
||||
import {createSandbox} from 'sinon';
|
||||
import type {SinonSandbox} from 'sinon';
|
||||
import {exec} from 'teen_process';
|
||||
import type {Browser} from 'webdriverio';
|
||||
import {remote as wdio} from 'webdriverio';
|
||||
|
||||
import {runExtensionCommand} from '../../lib/cli/extension';
|
||||
import {DRIVER_TYPE} from '../../lib/constants';
|
||||
import {loadExtensions} from '../../lib/extension';
|
||||
import {INSTALL_TYPE_LOCAL} from '../../lib/extension/extension-config';
|
||||
import {removeAppiumPrefixes} from '../../lib/helpers/capability';
|
||||
import {main as appiumServer} from '../../lib/main';
|
||||
import {FAKE_DRIVER_DIR, getTestPort, TEST_FAKE_APP, TEST_HOST, W3C_PREFIXED_CAPS} from '../helpers';
|
||||
import {runExtensionCommand} from '../../lib/cli/extension.js';
|
||||
import {DRIVER_TYPE} from '../../lib/constants.js';
|
||||
import {loadExtensions} from '../../lib/extension/index.js';
|
||||
import {INSTALL_TYPE_LOCAL} from '../../lib/extension/manifest/index.js';
|
||||
import {removeAppiumPrefixes} from '../../lib/helpers/capability.js';
|
||||
import {main as appiumServer} from '../../lib/main.js';
|
||||
import {FAKE_DRIVER_DIR, getTestPort, TEST_FAKE_APP, TEST_HOST, W3C_PREFIXED_CAPS} from '../helpers.js';
|
||||
|
||||
let testServerBaseUrl: string;
|
||||
let port: number;
|
||||
@@ -125,16 +125,13 @@ describe('FakeDriver via HTTP', function () {
|
||||
});
|
||||
|
||||
it('should allow drivers to update the server in arbitrary ways', async function () {
|
||||
const {data} = await axios.get(`${testServerBaseUrl}/fakedriver`);
|
||||
const {data} = await httpGet(`${testServerBaseUrl}/fakedriver`);
|
||||
assert.deepStrictEqual(data, {fakedriver: 'fakeResponse'});
|
||||
});
|
||||
it('should update the server with cliArgs', async function () {
|
||||
// we don't need to check the entire object, since it's large, but we can ensure an
|
||||
// arg got through.
|
||||
assert.strictEqual(
|
||||
(await axios.post(`http://${TEST_HOST}:${port}/fakedriverCliArgs`)).data.appiumHome,
|
||||
appiumHome,
|
||||
);
|
||||
assert.strictEqual((await httpPost(`http://${TEST_HOST}:${port}/fakedriverCliArgs`)).data.appiumHome, appiumHome);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,7 +149,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
const driver = await wdio({...wdOpts, capabilities: caps});
|
||||
const {sessionId} = driver;
|
||||
try {
|
||||
const {data} = await axios.get(`${testServerBaseSessionUrl}/${sessionId}/fakedriverargs`);
|
||||
const {data} = await httpGet(`${testServerBaseSessionUrl}/${sessionId}/fakedriverargs`);
|
||||
assert.ok(!data.value.sillyWebServerPort);
|
||||
assert.ok(!data.value.sillyWebServerHost);
|
||||
} finally {
|
||||
@@ -174,7 +171,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
const driver = await wdio({...wdOpts, capabilities: caps});
|
||||
const {sessionId} = driver;
|
||||
try {
|
||||
const {data} = await axios.get(`${testServerBaseSessionUrl}/${sessionId}/fakedriverargs`);
|
||||
const {data} = await httpGet(`${testServerBaseSessionUrl}/${sessionId}/fakedriverargs`);
|
||||
assert.strictEqual(data.value.sillyWebServerPort, sillyWebServerPort);
|
||||
assert.strictEqual(data.value.sillyWebServerHost, sillyWebServerHost);
|
||||
} finally {
|
||||
@@ -210,7 +207,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
};
|
||||
|
||||
// Create the session
|
||||
const {value} = (await axios.post(testServerBaseSessionUrl, appiumOptsCaps)).data;
|
||||
const {value} = (await httpPost(testServerBaseSessionUrl, appiumOptsCaps)).data;
|
||||
try {
|
||||
assert.strictEqual(typeof value.sessionId, 'string');
|
||||
assert.ok(value);
|
||||
@@ -222,7 +219,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
});
|
||||
} finally {
|
||||
// End session
|
||||
await axios.delete(`${testServerBaseSessionUrl}/${value.sessionId}`);
|
||||
await httpDelete(`${testServerBaseSessionUrl}/${value.sessionId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -251,7 +248,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
it('should list available driver commands', async function () {
|
||||
driver.addCommand(
|
||||
'listCommands',
|
||||
async () => (await axios.get(`${testServerBaseSessionUrl}/${driver.sessionId}/appium/commands`)).data.value,
|
||||
async () => (await httpGet(`${testServerBaseSessionUrl}/${driver.sessionId}/appium/commands`)).data.value,
|
||||
);
|
||||
|
||||
const commands = await driver.listCommands();
|
||||
@@ -275,6 +272,10 @@ describe('FakeDriver via HTTP', function () {
|
||||
name: 'contexts',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'userContexts',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -285,7 +286,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
it('should list available driver extensions', async function () {
|
||||
driver.addCommand(
|
||||
'listExtensions',
|
||||
async () => (await axios.get(`${testServerBaseSessionUrl}/${driver.sessionId}/appium/extensions`)).data.value,
|
||||
async () => (await httpGet(`${testServerBaseSessionUrl}/${driver.sessionId}/appium/extensions`)).data.value,
|
||||
);
|
||||
|
||||
const extensions = await driver.listExtensions();
|
||||
@@ -334,16 +335,6 @@ describe('FakeDriver via HTTP', function () {
|
||||
await driver2.deleteSession();
|
||||
});
|
||||
|
||||
it('should not be able to run two FakeDriver sessions simultaneously when one is unique', async function () {
|
||||
const uniqueCaps = structuredClone(caps);
|
||||
uniqueCaps['appium:uniqueApp'] = true;
|
||||
const driver1 = await wdio({...wdOpts, capabilities: uniqueCaps});
|
||||
assert.ok(driver1.sessionId);
|
||||
assert.strictEqual(typeof driver1.sessionId, 'string');
|
||||
await assert.rejects(wdio({...wdOpts, capabilities: caps}));
|
||||
await driver1.deleteSession();
|
||||
});
|
||||
|
||||
it('should use the newCommandTimeout of the inner Driver on session creation', async function () {
|
||||
const localCaps = {
|
||||
'appium:newCommandTimeout': 0.25,
|
||||
@@ -363,7 +354,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
};
|
||||
const driver = await wdio({...wdOpts, capabilities: localCaps});
|
||||
assert.ok(driver.sessionId);
|
||||
driver.addCommand('getStatus', async () => (await axios.get(`${testServerBaseUrl}/status`)).data.value);
|
||||
driver.addCommand('getStatus', async () => (await httpGet(`${testServerBaseUrl}/status`)).data.value);
|
||||
|
||||
// get the session list 6 times over 300ms. each request will be below the new command
|
||||
// timeout but since they are not received by the driver the session should still time out
|
||||
@@ -384,7 +375,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
};
|
||||
|
||||
// Create the session
|
||||
const {status, value, sessionId} = (await axios.post(testServerBaseSessionUrl, w3cCaps)).data;
|
||||
const {status, value, sessionId} = (await httpPost(testServerBaseSessionUrl, w3cCaps)).data;
|
||||
try {
|
||||
assert.ok(!status); // Test that it's a W3C session
|
||||
assert.ok(!sessionId);
|
||||
@@ -399,16 +390,14 @@ describe('FakeDriver via HTTP', function () {
|
||||
|
||||
// Now use that sessionId to call /screenshot
|
||||
const {status: screenshotStatus, value: screenshotValue} = (
|
||||
await axios({
|
||||
url: `${testServerBaseSessionUrl}/${value.sessionId}/screenshot`,
|
||||
})
|
||||
await httpGet(`${testServerBaseSessionUrl}/${value.sessionId}/screenshot`)
|
||||
).data;
|
||||
assert.ok(!screenshotStatus);
|
||||
assert.match(screenshotValue, /^iVBOR/); // should be a png
|
||||
|
||||
// Now use that sessionID to call an arbitrary W3C-only endpoint that isn't implemented to see if it responds with correct error
|
||||
await assert.rejects(
|
||||
axios.post(`${testServerBaseSessionUrl}/${value.sessionId}/execute/async`, {
|
||||
httpPost(`${testServerBaseSessionUrl}/${value.sessionId}/execute/async`, {
|
||||
script: '',
|
||||
args: ['a'],
|
||||
}),
|
||||
@@ -416,7 +405,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
);
|
||||
} finally {
|
||||
// End session
|
||||
await axios.delete(`${testServerBaseSessionUrl}/${value.sessionId}`);
|
||||
await httpDelete(`${testServerBaseSessionUrl}/${value.sessionId}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -437,7 +426,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
};
|
||||
|
||||
// Create the session
|
||||
const {status, value, sessionId} = (await axios.post(testServerBaseSessionUrl, appiumOptsCaps)).data;
|
||||
const {status, value, sessionId} = (await httpPost(testServerBaseSessionUrl, appiumOptsCaps)).data;
|
||||
try {
|
||||
assert.ok(!status); // Test that it's a W3C session
|
||||
assert.ok(!sessionId);
|
||||
@@ -451,7 +440,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
});
|
||||
} finally {
|
||||
// End session
|
||||
await axios.delete(`${testServerBaseSessionUrl}/${value.sessionId}`);
|
||||
await httpDelete(`${testServerBaseSessionUrl}/${value.sessionId}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -463,7 +452,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(axios.post(testServerBaseSessionUrl, badW3Ccaps), /400/);
|
||||
await assert.rejects(httpPost(testServerBaseSessionUrl, badW3Ccaps), /400/);
|
||||
});
|
||||
|
||||
it('should accept a combo of W3C and JSONWP capabilities but completely ignore JSONWP', async function () {
|
||||
@@ -482,7 +471,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
},
|
||||
};
|
||||
|
||||
const {status, value, sessionId} = (await axios.post(testServerBaseSessionUrl, combinedCaps)).data;
|
||||
const {status, value, sessionId} = (await httpPost(testServerBaseSessionUrl, combinedCaps)).data;
|
||||
try {
|
||||
assert.ok(!status);
|
||||
assert.ok(!sessionId);
|
||||
@@ -493,7 +482,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
});
|
||||
} finally {
|
||||
// End session
|
||||
await axios.delete(`${testServerBaseSessionUrl}/${value.sessionId}`);
|
||||
await httpDelete(`${testServerBaseSessionUrl}/${value.sessionId}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -506,7 +495,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
},
|
||||
},
|
||||
};
|
||||
await assert.rejects(axios.post(testServerBaseSessionUrl, w3cCaps), /500/);
|
||||
await assert.rejects(httpPost(testServerBaseSessionUrl, w3cCaps), /500/);
|
||||
});
|
||||
|
||||
it('should accept capabilities that are provided in the firstMatch array', async function () {
|
||||
@@ -521,14 +510,14 @@ describe('FakeDriver via HTTP', function () {
|
||||
],
|
||||
},
|
||||
};
|
||||
const {value, sessionId, status} = (await axios.post(testServerBaseSessionUrl, w3cCaps)).data;
|
||||
const {value, sessionId, status} = (await httpPost(testServerBaseSessionUrl, w3cCaps)).data;
|
||||
try {
|
||||
assert.ok(!status); // Test that it's a W3C session
|
||||
assert.ok(!sessionId);
|
||||
assert.deepStrictEqual(value.capabilities, removeAppiumPrefixes(caps));
|
||||
} finally {
|
||||
// End session
|
||||
await axios.delete(`${testServerBaseSessionUrl}/${value.sessionId}`);
|
||||
await httpDelete(`${testServerBaseSessionUrl}/${value.sessionId}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -550,8 +539,8 @@ describe('FakeDriver via HTTP', function () {
|
||||
],
|
||||
},
|
||||
};
|
||||
const res = await axios.post(testServerBaseSessionUrl, combinedCaps, {
|
||||
validateStatus: null,
|
||||
const res = await httpPost(testServerBaseSessionUrl, combinedCaps, {
|
||||
throwOnError: false,
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.match(res.data.value.error, /invalid argument/);
|
||||
@@ -570,24 +559,23 @@ describe('FakeDriver via HTTP', function () {
|
||||
},
|
||||
};
|
||||
let sessionId = null;
|
||||
const createSessionStub = sandbox.stub(FakeDriver.prototype, 'createSession').callsFake(async function (
|
||||
this: InstanceType<DriverClass>,
|
||||
caps,
|
||||
) {
|
||||
const res = await BaseDriver.prototype.createSession.call(this, caps);
|
||||
sessionId = res[0];
|
||||
assert.strictEqual(this.protocol, 'W3C');
|
||||
return res;
|
||||
});
|
||||
const createSessionStub = sandbox
|
||||
.stub(FakeDriver.prototype, 'createSession')
|
||||
.callsFake(async function (this: InstanceType<DriverClass>, caps) {
|
||||
const res = await BaseDriver.prototype.createSession.call(this, caps);
|
||||
sessionId = res[0];
|
||||
assert.strictEqual(this.protocol, 'W3C');
|
||||
return res;
|
||||
});
|
||||
try {
|
||||
const res = await axios.post(testServerBaseSessionUrl, combinedCaps, {
|
||||
validateStatus: null,
|
||||
const res = await httpPost(testServerBaseSessionUrl, combinedCaps, {
|
||||
throwOnError: false,
|
||||
});
|
||||
const {status} = res;
|
||||
assert.strictEqual(status, 200);
|
||||
} finally {
|
||||
if (sessionId) {
|
||||
await axios.delete(`${testServerBaseSessionUrl}/${sessionId}`);
|
||||
await httpDelete(`${testServerBaseSessionUrl}/${sessionId}`);
|
||||
}
|
||||
createSessionStub.restore();
|
||||
}
|
||||
@@ -597,10 +585,10 @@ describe('FakeDriver via HTTP', function () {
|
||||
const driver = await wdio({...wdOpts, capabilities: caps});
|
||||
const {sessionId} = driver;
|
||||
try {
|
||||
await axios.post(`${testServerBaseSessionUrl}/${sessionId}/fakedriver`, {
|
||||
await httpPost(`${testServerBaseSessionUrl}/${sessionId}/fakedriver`, {
|
||||
thing: {yes: 'lolno'},
|
||||
});
|
||||
assert.deepStrictEqual((await axios.get(`${testServerBaseSessionUrl}/${sessionId}/fakedriver`)).data.value, {
|
||||
assert.deepStrictEqual((await httpGet(`${testServerBaseSessionUrl}/${sessionId}/fakedriver`)).data.value, {
|
||||
yes: 'lolno',
|
||||
});
|
||||
} finally {
|
||||
@@ -727,8 +715,7 @@ describe('FakeDriver via HTTP', function () {
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: This test is skipped due to spdy package incompatibility
|
||||
describe('Bidi over SSL', {skip: true}, function () {
|
||||
describe('Bidi over SSL', function () {
|
||||
async function generateCertificate(certPath: string, keyPath: string) {
|
||||
await exec('openssl', [
|
||||
'req',
|
||||
@@ -783,8 +770,11 @@ describe('Bidi over SSL', {skip: true}, function () {
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = previousEnvValue;
|
||||
await driver?.deleteSession();
|
||||
try {
|
||||
await driver?.deleteSession();
|
||||
} finally {
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = previousEnvValue;
|
||||
}
|
||||
});
|
||||
|
||||
it('should still run bidi over ssl', async function () {
|
||||
@@ -799,8 +789,7 @@ describe('Bidi over SSL', {skip: true}, function () {
|
||||
});
|
||||
});
|
||||
|
||||
// TODO this test only works if the log has not previously been initialized in the same process.
|
||||
describe.skip('Logsink', function () {
|
||||
describe('Logsink', function () {
|
||||
let server: Awaited<ReturnType<typeof appiumServer>> | null = null;
|
||||
const logs: [string, string][] = [];
|
||||
const logHandler = function (level: string, message: string) {
|
||||
|
||||
@@ -5,11 +5,11 @@ import path from 'node:path';
|
||||
*/
|
||||
import {console as supportConsole, fs} from '@appium/support';
|
||||
import type {DriverType, PluginType} from '@appium/types';
|
||||
import type {CliExtensionSubcommand, ExtRecord} from 'appium/types';
|
||||
import type {CliExtensionSubcommand, ExtRecord} from 'appium/types/index.js';
|
||||
import {exec} from 'teen_process';
|
||||
import type {ExecError} from 'teen_process';
|
||||
|
||||
import {APPIUM_ROOT, resolveFixture} from '../helpers';
|
||||
import {APPIUM_ROOT, resolveFixture} from '../helpers.js';
|
||||
|
||||
export const EXECUTABLE = path.join(APPIUM_ROOT, 'build', 'lib', 'main.js');
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user