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