feat(driver-test-support): create package

This package contains driver-specific test utilities extracted from `@appium/base-driver` by way of `@appium/test-support`
This commit is contained in:
Christopher Hiller
2022-08-22 15:07:43 -07:00
parent 58d359d944
commit 41289724dc
11 changed files with 1420 additions and 0 deletions

View File

@@ -83,6 +83,7 @@
"@appium/base-plugin": "file:packages/base-plugin",
"@appium/doctor": "file:packages/doctor",
"@appium/docutils": "file:packages/docutils",
"@appium/driver-test-support": "file:packages/driver-test-support",
"@appium/eslint-config-appium": "file:packages/eslint-config-appium",
"@appium/execute-driver-plugin": "file:packages/execute-driver-plugin",
"@appium/fake-driver": "file:packages/fake-driver",

View File

@@ -0,0 +1,112 @@
# @appium/driver-test-support
> Testing utilities for [Appium](https://appium.io) drivers
This package is for driver authors to help test their drivers.
[Mocha](https://mochajs.org) is the supported test framework.
## Usage
### For E2E Tests
The `driverE2ETestSuite` method creates a Mocha test suite which makes HTTP requests to an in-memory server leveraging your driver.
Note that this method must be run within a _suite callback_—not a _test callback_.
```js
import {driverE2ETestSuite} from '@appium/driver-test-support';
const defaultW3CCapabilities = {
// some capabilities
};
describe('MyDriverClass', function() {
driverE2ETestSuite(MyDriverClass, defaultW3CCapabilities);
describe('more tests', function() {
// ...
});
});
```
### For Unit Tests
The `driverUnitTestSuite` method creates a Mocha test suite which performs assertions on an isolated instance of your driver.
Note that this method must be run within a _suite callback_—not a _test callback_.
```js
import {driverUnitTestSuite} from '@appium/driver-test-support';
const defaultW3CCapabilities = {
// some capabilities
};
describe('MyDriverClass', function() {
driverUnitTestSuite(MyDriverClass, defaultW3CCapabilities);
describe('more tests', function() {
// ...
});
});
```
### Helpers
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() {
assert.strictEqual(TEST_HOST, '127.0.0.1');
});
});
describe('getTestPort()', function() {
it('should get a free test port', async function() {
const port = await getTestPort();
assert.ok(port > 0);
});
});
describe('createAppiumURL()', function() {
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);
});
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`;
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);
});
});
```
## Installation
`appium` and `mocha` are peer dependencies.
```bash
npm install appium mocha @appium/driver-test-support --save-dev
```
## License
Apache-2.0

View File

@@ -0,0 +1 @@
module.exports = require('./build/lib');

View File

@@ -0,0 +1,465 @@
import _ from 'lodash';
import {server, routeConfiguringFunction, DeviceSettings} from 'appium/driver';
import axios from 'axios';
import B from 'bluebird';
import {TEST_HOST, getTestPort, createAppiumURL} from './helpers';
import chai from 'chai';
import sinon from 'sinon';
const should = chai.should();
/**
* Creates some helper functions for E2E tests to manage sessions.
* @template [CommandData=unknown]
* @template [ResponseData=any]
* @param {number} port - Port on which the server is running. Typically this will be retrieved via `get-port` beforehand
* @param {string} [address] - Address/host on which the server is running. Defaults to {@linkcode TEST_HOST}
* @returns {SessionHelpers<CommandData, ResponseData>}
*/
export function createSessionHelpers(port, address = TEST_HOST) {
const createAppiumTestURL =
/** @type {import('lodash').CurriedFunction2<string,string,string>} */ (
createAppiumURL(address, port)
);
const createSessionURL = createAppiumTestURL(_, '');
const newSessionURL = createAppiumTestURL('', 'session');
return /** @type {SessionHelpers<CommandData, ResponseData>} */ ({
newSessionURL,
createAppiumTestURL,
/**
*
* @param {string} sessionId
* @param {string} cmdName
* @param {any} [data]
* @param {AxiosRequestConfig} [config]
* @returns {Promise<any>}
*/
postCommand: async (sessionId, cmdName, data = {}, config = {}) => {
const url = createAppiumTestURL(sessionId, cmdName);
const response = await axios.post(url, data, config);
return response.data?.value;
},
/**
*
* @param {string} sessionIdOrCmdName
* @param {string|AxiosRequestConfig} cmdNameOrConfig
* @param {AxiosRequestConfig} [config]
* @returns {Promise<any>}
*/
getCommand: async (sessionIdOrCmdName, cmdNameOrConfig, config = {}) => {
if (!_.isString(cmdNameOrConfig)) {
config = cmdNameOrConfig;
cmdNameOrConfig = sessionIdOrCmdName;
sessionIdOrCmdName = '';
}
const response = await axios({
url: createAppiumTestURL(sessionIdOrCmdName, cmdNameOrConfig),
validateStatus: null,
...config,
});
return response.data?.value;
},
/**
*
* @param {NewSessionData} data
* @param {AxiosRequestConfig} [config]
*/
startSession: async (data, config = {}) => {
data = _.defaultsDeep(data, {
capabilities: {
alwaysMatch: {},
firstMatch: [{}],
},
});
const response = await axios.post(newSessionURL, data, config);
return response.data?.value;
},
/**
*
* @param {string} sessionId
*/
endSession: async (sessionId) =>
await axios.delete(createSessionURL(sessionId), {
validateStatus: null,
}),
/**
* @param {string} sessionId
* @returns {Promise<any>}
*/
getSession: async (sessionId) => {
const response = await axios({
url: createSessionURL(sessionId),
validateStatus: null,
});
return response.data?.value;
},
});
}
/**
* Creates E2E test suites for a driver.
* @template {Driver} P
* @param {DriverClass<P>} DriverClass
* @param {AppiumW3CCapabilities} [defaultCaps]
*/
export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
let address = defaultCaps['appium:address'] ?? TEST_HOST;
let port = defaultCaps['appium:port'];
const className = DriverClass.name || '(unknown driver)';
describe(`BaseDriver E2E (as ${className})`, function () {
let baseServer;
/** @type {P} */
let d;
/**
* This URL creates a new session
* @type {string}
**/
let newSessionURL;
/** @type {SessionHelpers['startSession']} */
let startSession;
/** @type {SessionHelpers['getSession']} */
let getSession;
/** @type {SessionHelpers['endSession']} */
let endSession;
/** @type {SessionHelpers['getCommand']} */
let getCommand;
/** @type {SessionHelpers['postCommand']} */
let postCommand;
before(async function () {
port = port ?? (await getTestPort());
defaultCaps = {...defaultCaps, 'appium:port': port};
d = new DriverClass({port, address});
baseServer = await server({
routeConfiguringFunction: routeConfiguringFunction(d),
port,
hostname: address,
// @ts-expect-error
cliArgs: {},
});
({startSession, getSession, endSession, newSessionURL, getCommand, postCommand} =
createSessionHelpers(port, address));
});
after(async function () {
await baseServer.close();
});
describe('session handling', function () {
it('should handle idempotency while creating sessions', async function () {
const sessionIds = [];
let times = 0;
do {
const {sessionId} = await startSession(
{
capabilities: {alwaysMatch: defaultCaps},
},
{
headers: {
'X-Idempotency-Key': '123456',
},
// XXX: I'm not sure what these are, as they are not documented axios options,
// nor are they mentioned in our source
// @ts-expect-error
simple: false,
resolveWithFullResponse: true,
}
);
sessionIds.push(sessionId);
times++;
} while (times < 2);
_.uniq(sessionIds).length.should.equal(1);
const {status, data} = await endSession(sessionIds[0]);
status.should.equal(200);
should.equal(data.value, null);
});
it('should handle idempotency while creating parallel sessions', async function () {
const reqs = [];
let times = 0;
do {
reqs.push(
startSession(
{
capabilities: {
alwaysMatch: defaultCaps,
},
},
{
headers: {
'X-Idempotency-Key': '12345',
},
}
)
);
times++;
} while (times < 2);
const sessionIds = _.map(await B.all(reqs), 'sessionId');
_.uniq(sessionIds).length.should.equal(1);
const {status, data} = await endSession(sessionIds[0]);
status.should.equal(200);
should.equal(data.value, null);
});
it('should create session and retrieve a session id, then delete it', async function () {
let {status, data} = await axios.post(newSessionURL, {
capabilities: {
alwaysMatch: defaultCaps,
},
});
status.should.equal(200);
should.exist(data.value.sessionId);
data.value.capabilities.platformName.should.equal(defaultCaps.platformName);
data.value.capabilities.deviceName.should.equal(defaultCaps['appium:deviceName']);
({status, data} = await endSession(/** @type {string} */ (d.sessionId)));
status.should.equal(200);
should.equal(data.value, null);
should.equal(d.sessionId, null);
});
});
it.skip('should throw NYI for commands not implemented', async function () {});
describe('command timeouts', function () {
let originalFindElement, originalFindElements;
/**
* @param {number} [timeout]
*/
async function startTimeoutSession(timeout) {
const caps = _.cloneDeep(defaultCaps);
caps['appium:newCommandTimeout'] = timeout;
return await startSession({capabilities: {alwaysMatch: caps}});
}
before(function () {
originalFindElement = d.findElement;
d.findElement = function () {
return 'foo';
}.bind(d);
originalFindElements = d.findElements;
d.findElements = async function () {
await B.delay(200);
return ['foo'];
}.bind(d);
});
after(function () {
d.findElement = originalFindElement;
d.findElements = originalFindElements;
});
it('should set a default commandTimeout', async function () {
let newSession = await startTimeoutSession();
d.newCommandTimeoutMs.should.be.above(0);
await endSession(newSession.sessionId);
});
it('should timeout on commands using commandTimeout cap', async function () {
let newSession = await startTimeoutSession(0.25);
const sessionId = /** @type {string} */ (d.sessionId);
await postCommand(sessionId, 'element', {
using: 'name',
value: 'foo',
});
await B.delay(400);
const value = await getSession(sessionId);
should.equal(value.error, 'invalid session id');
should.equal(d.sessionId, null);
const resp = (await endSession(newSession.sessionId)).data.value;
should.equal(resp?.error, 'invalid session id');
});
it('should not timeout with commandTimeout of false', async function () {
let newSession = await startTimeoutSession(0.1);
let start = Date.now();
const value = await postCommand(/** @type {string} */ (d.sessionId), 'elements', {
using: 'name',
value: 'foo',
});
(Date.now() - start).should.be.above(150);
value.should.eql(['foo']);
await endSession(newSession.sessionId);
});
it('should not timeout with commandTimeout of 0', async function () {
d.newCommandTimeoutMs = 2;
let newSession = await startTimeoutSession(0);
await postCommand(/** @type {string} */ (d.sessionId), 'element', {
using: 'name',
value: 'foo',
});
await B.delay(400);
const value = await getSession(/** @type {string} */ (d.sessionId));
value.platformName?.should.equal(defaultCaps.platformName);
const resp = (await endSession(newSession.sessionId)).data.value;
should.equal(resp, null);
d.newCommandTimeoutMs = 60 * 1000;
});
it('should not timeout if its just the command taking awhile', async function () {
let newSession = await startTimeoutSession(0.25);
// XXX: race condition: we must build this URL before ...something happens...
// which causes `d.sessionId` to be missing
const {sessionId} = d;
await postCommand(/** @type {string} */ (d.sessionId), 'element', {
using: 'name',
value: 'foo',
});
await B.delay(400);
const value = await getSession(/** @type {string} */ (sessionId));
value.error.should.equal('invalid session id');
should.equal(d.sessionId, null);
const resp = (await endSession(newSession.sessionId)).data.value;
/** @type {string} */ (/** @type { {error: string} } */ (resp).error).should.equal(
'invalid session id'
);
});
it('should not have a timer running before or after a session', async function () {
// @ts-expect-error
should.not.exist(d.noCommandTimer);
let newSession = await startTimeoutSession(0.25);
newSession.sessionId.should.equal(d.sessionId);
// @ts-expect-error
should.exist(d.noCommandTimer);
await endSession(newSession.sessionId);
// @ts-expect-error
should.not.exist(d.noCommandTimer);
});
});
describe('settings api', function () {
before(function () {
d.settings = new DeviceSettings({ignoreUnimportantViews: false});
});
it('should be able to get settings object', function () {
d.settings.getSettings().ignoreUnimportantViews.should.be.false;
});
it('should not reject when `updateSettings` method is not provided', async function () {
await d.settings.update({ignoreUnimportantViews: true}).should.not.be.rejected;
});
it('should reject for invalid update object', async function () {
await d.settings
// @ts-expect-error
.update('invalid json')
.should.be.rejectedWith('JSON');
});
});
describe('unexpected exits', function () {
/** @type {import('sinon').SinonSandbox} */
let sandbox;
beforeEach(function () {
sandbox = sinon.createSandbox();
});
afterEach(function () {
sandbox.restore();
});
it('should reject a current command when the driver crashes', async function () {
sandbox.stub(d, 'getStatus').callsFake(async function () {
await B.delay(5000);
});
const reqPromise = getCommand('status', {validateStatus: null});
// make sure that the request gets to the server before our shutdown
await B.delay(100);
const shutdownEventPromise = new B((resolve, reject) => {
setTimeout(
() =>
reject(
new Error(
'onUnexpectedShutdown event is expected to be fired within 5 seconds timeout'
)
),
5000
);
d.onUnexpectedShutdown(resolve);
});
d.startUnexpectedShutdown(new Error('Crashytimes'));
const value = await reqPromise;
value.message.should.contain('Crashytimes');
await shutdownEventPromise;
});
});
describe('event timings', function () {
it('should not add timings if not using opt-in cap', async function () {
const session = await startSession({capabilities: {alwaysMatch: defaultCaps}});
const res = await getSession(session.sessionId);
should.not.exist(res.events);
await endSession(session.sessionId);
});
it('should add start session timings', async function () {
const caps = {...defaultCaps, 'appium:eventTimings': true};
const session = await startSession({capabilities: {alwaysMatch: caps}});
const res = await getSession(session.sessionId);
should.exist(res.events);
should.exist(res.events?.newSessionRequested);
should.exist(res.events?.newSessionStarted);
res.events?.newSessionRequested[0].should.be.a('number');
res.events?.newSessionStarted[0].should.be.a('number');
await endSession(session.sessionId);
});
});
});
}
/**
* A {@linkcode DriverClass}, except using the base {@linkcode Driver} type instead of `ExternalDriver`.
* This allows the suite to work for `BaseDriver`.
* @template {Driver} P
* @typedef {import('@appium/types').DriverClass<P>} DriverClass
*/
/**
* @typedef {import('@appium/types').Capabilities} Capabilities
* @typedef {import('@appium/types').Driver} Driver
* @typedef {import('@appium/types').DriverStatic} DriverStatic
* @typedef {import('@appium/types').AppiumW3CCapabilities} AppiumW3CCapabilities
* @typedef {import('axios').AxiosRequestConfig} AxiosRequestConfig
* @typedef {import('@appium/types').SingularSessionData} SingularSessionData
*/
/**
* @template T,D
* @typedef {import('axios').AxiosResponse<T, D>} AxiosResponse
*/
/**
* @typedef NewSessionData
* @property {import('type-fest').RequireAtLeastOne<import('@appium/types').W3CCapabilities, 'firstMatch'|'alwaysMatch'>} capabilities
*/
/**
* @typedef NewSessionResponse
* @property {string} sessionId,
* @property {import('@appium/types').Capabilities} capabilities
*/
/**
* Some E2E helpers for making requests and managing sessions
* See {@linkcode createSessionHelpers}
* @template [CommandData=unknown]
* @template [ResponseData=any]
* @typedef SessionHelpers
* @property {string} newSessionURL - URL to create a new session. Can be used with raw `axios` requests to fully inspect raw response. Mostly, this will not be used.
* @property {(data: NewSessionData, config?: AxiosRequestConfig) => Promise<NewSessionResponse>} startSession - Begin a session
* @property {(sessionId: string) => Promise<AxiosResponse<{value: {error?: string}?}, {validateStatus: null}>>} endSession - End a session. _Note: resolves with raw response object_
* @property {(sessionId: string) => Promise<SingularSessionData>} getSession - Get info about a session
* @property {(sessionId: string, cmdName: string, data?: CommandData, config?: AxiosRequestConfig) => Promise<ResponseData>} postCommand - Send an arbitrary command via `POST`.
* @property {(sessionIdOrCmdName: string, cmdNameOrConfig: string|AxiosRequestConfig, config?: AxiosRequestConfig) => Promise<ResponseData>} getCommand - Send an arbitrary command via `GET`. Optional `sessionId`.
*/

View File

@@ -0,0 +1,68 @@
import getPort from 'get-port';
import {curry} from 'lodash';
/**
* Default test host
*/
const TEST_HOST = '127.0.0.1';
let testPort;
/**
* Returns a free port; one per process
* @param {boolean} [force] - If true, do not reuse the port (if it already exists)
* @returns {Promise<number>} a free port
*/
async function getTestPort(force = false) {
if (force || !testPort) {
let port = await getPort();
if (!testPort) {
testPort = port;
}
return port;
}
return testPort;
}
/**
* Build an Appium URL from components.
*
* **All** parameters are required. Provide an empty string (`''`) if you don't need one.
* To rearrange arguments (if needed), use the placeholder from Lodash (`_`).
*
*/
const createAppiumURL = curry(
/**
* @param {string} address - Base address (w/ optional protocol)
* @param {string|number} port - Port number
* @param {string?} session - Session ID
* @param {string} pathname - Extra path
* @returns {string} New URL
* @example
*
* import _ from 'lodash';
*
* // http://127.0.0.1:31337/session
* createAppiumURL('127.0.0.1', 31337, '', 'session')
*
* // http://127.0.0.1:31337/session/asdfgjkl
* const createSessionURL = createAppiumURL('127.0.0.1', 31337, _, 'session')
* createSessionURL('asdfgjkl')
*
* // http://127.0.0.1:31337/session/asdfgjkl/appium/execute
* const createURLWithPath = createAppiumURL('127.0.0.1', 31337, 'asdfgjkl');
* createURLWithPath('appium/execute')
*/
(address, port, session, pathname) => {
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 {TEST_HOST, getTestPort, createAppiumURL};

View File

@@ -0,0 +1,9 @@
export * from './e2e-suite';
export * from './unit-suite';
export * from './helpers';
/**
* @typedef {import('@appium/types').DriverClass} DriverClass
* @typedef {import('@appium/types').W3CCapabilities} W3CCapabilities
* @typedef {import('@appium/types').AppiumW3CCapabilities} AppiumW3CCapabilities
*/

View File

@@ -0,0 +1,631 @@
import _ from 'lodash';
import B from 'bluebird';
import {createSandbox} from 'sinon';
import chai from 'chai';
const should = chai.should();
const {expect} = chai;
// wrap these tests in a function so we can export the tests and re-use them
// for actual driver implementations
/**
* Creates unit test suites for a driver.
* @param {DriverClass} DriverClass
* @param {AppiumW3CCapabilities} [defaultCaps]
*/
export function driverUnitTestSuite(DriverClass, defaultCaps = {}) {
// to display the driver under test in report
const className = DriverClass.name ?? '(unknown driver)';
describe(`BaseDriver unit suite (as ${className})`, function () {
/** @type {InstanceType<typeof DriverClass>} */
let d;
/** @type {W3CCapabilities} */
let w3cCaps;
/** @type {import('sinon').SinonSandbox} */
let sandbox;
beforeEach(function () {
sandbox = createSandbox();
d = new DriverClass();
w3cCaps = {
alwaysMatch: {
...defaultCaps,
platformName: 'Fake',
'appium:deviceName': 'Commodore 64',
},
firstMatch: [{}],
};
});
afterEach(async function () {
sandbox.restore();
await d.deleteSession();
});
describe('static property', function () {
describe('baseVersion', function () {
it('should exist', function () {
DriverClass.baseVersion.should.exist;
});
});
});
describe('Log prefix', function () {
it('should setup log prefix', async function () {
const d = new DriverClass();
const previousPrefix = d.log.prefix;
await d.createSession({
alwaysMatch: {...defaultCaps, platformName: 'Fake', 'appium:deviceName': 'Commodore 64'},
firstMatch: [{}],
});
try {
expect(previousPrefix).not.to.eql(d.log.prefix);
} finally {
await d.deleteSession();
expect(previousPrefix).to.eql(d.log.prefix);
}
});
});
it('should return an empty status object', async function () {
let status = await d.getStatus();
status.should.eql({});
});
it('should return a sessionId from createSession', async function () {
let [sessId] = await d.createSession(w3cCaps);
should.exist(sessId);
sessId.should.be.a('string');
sessId.length.should.be.above(5);
});
it('should not be able to start two sessions without closing the first', async function () {
await d.createSession(_.cloneDeep(w3cCaps));
await d.createSession(_.cloneDeep(w3cCaps)).should.be.rejectedWith('session');
});
it('should be able to delete a session', async function () {
let sessionId1 = await d.createSession(_.cloneDeep(w3cCaps));
await d.deleteSession();
should.equal(d.sessionId, null);
let sessionId2 = await d.createSession(_.cloneDeep(w3cCaps));
sessionId1.should.not.eql(sessionId2);
});
it('should get the current session', async function () {
let [, caps] = await d.createSession(w3cCaps);
caps.should.equal(await d.getSession());
});
it('should return sessions if no session exists', async function () {
let sessions = await d.getSessions();
sessions.length.should.equal(0);
});
it('should return sessions', async function () {
const caps = _.cloneDeep(w3cCaps);
await d.createSession(caps);
let sessions = await d.getSessions();
sessions.length.should.equal(1);
sessions[0].should.include({
id: d.sessionId,
});
sessions[0].capabilities.should.include({
deviceName: 'Commodore 64',
platformName: 'Fake',
});
});
it('should fulfill an unexpected driver quit promise', async function () {
// make a command that will wait a bit so we can crash while it's running
sandbox.stub(d, 'getStatus').callsFake(async () => {
await B.delay(1000);
return 'good status';
});
let cmdPromise = d.executeCommand('getStatus');
await B.delay(10);
const p = new B((resolve, reject) => {
setTimeout(
() =>
reject(
new Error(
'onUnexpectedShutdown event is expected to be fired within 5 seconds timeout'
)
),
5000
);
d.onUnexpectedShutdown(resolve);
});
d.startUnexpectedShutdown(new Error('We crashed'));
await cmdPromise.should.be.rejectedWith(/We crashed/);
await p;
});
it('should not allow commands in middle of unexpected shutdown', async function () {
// make a command that will wait a bit so we can crash while it's running
sandbox.stub(d, 'deleteSession').callsFake(async function () {
await B.delay(100);
DriverClass.prototype.deleteSession.call(this);
});
await d.createSession(w3cCaps);
const p = new B((resolve, reject) => {
setTimeout(
() =>
reject(
new Error(
'onUnexpectedShutdown event is expected to be fired within 5 seconds timeout'
)
),
5000
);
d.onUnexpectedShutdown(resolve);
});
d.startUnexpectedShutdown(new Error('We crashed'));
await p;
await d.executeCommand('getSession').should.be.rejectedWith(/shut down/);
});
it('should allow new commands after done shutting down', async function () {
// make a command that will wait a bit so we can crash while it's running
sandbox.stub(d, 'deleteSession').callsFake(async function () {
await B.delay(100);
DriverClass.prototype.deleteSession.call(this);
});
await d.createSession(_.cloneDeep(w3cCaps));
const p = new B((resolve, reject) => {
setTimeout(
() =>
reject(
new Error(
'onUnexpectedShutdown event is expected to be fired within 5 seconds timeout'
)
),
5000
);
d.onUnexpectedShutdown(resolve);
});
d.startUnexpectedShutdown(new Error('We crashed'));
await p;
await d.executeCommand('getSession').should.be.rejectedWith(/shut down/);
await B.delay(500);
await d.executeCommand('createSession', null, null, _.cloneDeep(w3cCaps));
await d.deleteSession();
});
it('should distinguish between W3C and JSONWP session', async function () {
// Test W3C (leave first 2 args null because those are the JSONWP args)
await d.executeCommand('createSession', null, null, {
alwaysMatch: {
...defaultCaps,
platformName: 'Fake',
'appium:deviceName': 'Commodore 64',
},
firstMatch: [{}],
});
expect(d.protocol).to.equal('W3C');
});
describe('protocol detection', function () {
it('should use W3C if only W3C caps are provided', async function () {
await d.createSession({
alwaysMatch: _.clone(defaultCaps),
firstMatch: [{}],
});
expect(d.protocol).to.equal('W3C');
});
});
it('should have a method to get driver for a session', async function () {
let [sessId] = await d.createSession(w3cCaps);
expect(d.driverForSession(sessId)).to.eql(d);
});
describe('command queue', function () {
/** @type {InstanceType<DriverClass>} */
let d;
let waitMs = 10;
beforeEach(function () {
d = new DriverClass();
sandbox.stub(d, 'getStatus').callsFake(async () => {
await B.delay(waitMs);
return Date.now();
});
sandbox.stub(d, 'getSessions').callsFake(async () => {
await B.delay(waitMs);
throw new Error('multipass');
});
});
afterEach(async function () {
await d.clearNewCommandTimeout();
});
it('should queue commands and.executeCommand/respond in the order received', async function () {
let numCmds = 10;
let cmds = [];
for (let i = 0; i < numCmds; i++) {
cmds.push(d.executeCommand('getStatus'));
}
let results = await B.all(cmds);
for (let i = 1; i < numCmds; i++) {
if (results[i] <= results[i - 1]) {
throw new Error('Got result out of order');
}
}
});
it('should handle errors correctly when queuing', async function () {
let numCmds = 10;
let cmds = [];
for (let i = 0; i < numCmds; i++) {
if (i === 5) {
cmds.push(d.executeCommand('getSessions'));
} else {
cmds.push(d.executeCommand('getStatus'));
}
}
let results = /** @type {PromiseFulfilledResult<any>[]} */ (
// eslint-disable-next-line promise/no-native
await Promise.allSettled(cmds)
);
for (let i = 1; i < 5; i++) {
if (results[i].value <= results[i - 1].value) {
throw new Error('Got result out of order');
}
}
/** @type {PromiseRejectedResult} */ (
/** @type {unknown} */ (results[5])
).reason.message.should.contain('multipass');
for (let i = 7; i < numCmds; i++) {
if (results[i].value <= results[i - 1].value) {
throw new Error('Got result out of order');
}
}
});
it('should not care if queue empties for a bit', async function () {
let numCmds = 10;
let cmds = [];
for (let i = 0; i < numCmds; i++) {
cmds.push(d.executeCommand('getStatus'));
}
let results = await B.all(cmds);
cmds = [];
for (let i = 0; i < numCmds; i++) {
cmds.push(d.executeCommand('getStatus'));
}
results = await B.all(cmds);
for (let i = 1; i < numCmds; i++) {
if (results[i] <= results[i - 1]) {
throw new Error('Got result out of order');
}
}
});
});
describe('timeouts', function () {
before(async function () {
await d.createSession(w3cCaps);
});
describe('command', function () {
it('should exist by default', function () {
d.newCommandTimeoutMs.should.equal(60000);
});
it('should be settable through `timeouts`', async function () {
await d.timeouts('command', 20);
d.newCommandTimeoutMs.should.equal(20);
});
});
describe('implicit', function () {
it('should not exist by default', function () {
d.implicitWaitMs.should.equal(0);
});
it('should be settable through `timeouts`', async function () {
await d.timeouts('implicit', 20);
d.implicitWaitMs.should.equal(20);
});
});
});
describe('timeouts (W3C)', function () {
beforeEach(async function () {
await d.createSession(w3cCaps);
});
afterEach(async function () {
await d.deleteSession();
});
it('should get timeouts that we set', async function () {
// @ts-expect-error
await d.timeouts(undefined, undefined, undefined, undefined, 1000);
await d.getTimeouts().should.eventually.have.property('implicit', 1000);
await d.timeouts('command', 2000);
await d.getTimeouts().should.eventually.deep.equal({
implicit: 1000,
command: 2000,
});
// @ts-expect-error
await d.timeouts(undefined, undefined, undefined, undefined, 3000);
await d.getTimeouts().should.eventually.deep.equal({
implicit: 3000,
command: 2000,
});
});
});
describe('reset compatibility', function () {
it('should not allow both fullReset and noReset to be true', async function () {
const newCaps = {
alwaysMatch: {
...defaultCaps,
platformName: 'Fake',
'appium:deviceName': 'Commodore 64',
'appium:fullReset': true,
'appium:noReset': true,
},
firstMatch: [{}],
};
await d.createSession(newCaps).should.be.rejectedWith(/noReset.+fullReset/);
});
});
describe('proxying', function () {
let sessId;
beforeEach(async function () {
[sessId] = await d.createSession(w3cCaps);
});
describe('#proxyActive', function () {
it('should exist', function () {
d.proxyActive.should.be.an.instanceof(Function);
});
it('should return false', function () {
d.proxyActive(sessId).should.be.false;
});
it('should throw an error when sessionId is wrong', function () {
(() => {
d.proxyActive('aaa');
}).should.throw;
});
});
describe('#getProxyAvoidList', function () {
it('should exist', function () {
d.getProxyAvoidList.should.be.an.instanceof(Function);
});
it('should return an array', function () {
d.getProxyAvoidList(sessId).should.be.an.instanceof(Array);
});
it('should throw an error when sessionId is wrong', function () {
(() => {
d.getProxyAvoidList('aaa');
}).should.throw;
});
});
describe('#canProxy', function () {
it('should have a #canProxy method', function () {
d.canProxy.should.be.an.instanceof(Function);
});
it('should return a boolean from #canProxy', function () {
d.canProxy(sessId).should.be.a('boolean');
});
it('should throw an error when sessionId is wrong', function () {
(() => {
d.canProxy();
}).should.throw;
});
});
describe('#proxyRouteIsAvoided', function () {
it('should validate form of avoidance list', function () {
const avoidStub = sandbox.stub(d, 'getProxyAvoidList');
// @ts-expect-error
avoidStub.returns([['POST', /\/foo/], ['GET']]);
(() => {
// @ts-expect-error
d.proxyRouteIsAvoided();
}).should.throw;
avoidStub.returns([
['POST', /\/foo/],
// @ts-expect-error
['GET', /^foo/, 'bar'],
]);
(() => {
// @ts-expect-error
d.proxyRouteIsAvoided();
}).should.throw;
});
it('should reject bad http methods', function () {
const avoidStub = sandbox.stub(d, 'getProxyAvoidList');
avoidStub.returns([
['POST', /^foo/],
['BAZETE', /^bar/],
]);
(() => {
// @ts-expect-error
d.proxyRouteIsAvoided();
}).should.throw;
});
it('should reject non-regex routes', function () {
const avoidStub = sandbox.stub(d, 'getProxyAvoidList');
avoidStub.returns([
['POST', /^foo/],
// @ts-expect-error
['GET', '/bar'],
]);
(() => {
// @ts-expect-error
d.proxyRouteIsAvoided();
}).should.throw;
});
it('should return true for routes in the avoid list', function () {
const avoidStub = sandbox.stub(d, 'getProxyAvoidList');
avoidStub.returns([['POST', /^\/foo/]]);
d.proxyRouteIsAvoided('foo', 'POST', '/foo/bar').should.be.true;
});
it('should strip away any wd/hub prefix', function () {
const avoidStub = sandbox.stub(d, 'getProxyAvoidList');
avoidStub.returns([['POST', /^\/foo/]]);
d.proxyRouteIsAvoided('foo', 'POST', '/foo/bar').should.be.true;
});
it('should return false for routes not in the avoid list', function () {
const avoidStub = sandbox.stub(d, 'getProxyAvoidList');
avoidStub.returns([['POST', /^\/foo/]]);
d.proxyRouteIsAvoided('foo', 'GET', '/foo/bar').should.be.false;
d.proxyRouteIsAvoided('foo', 'POST', '/boo').should.be.false;
});
});
});
describe('event timing framework', function () {
let beforeStartTime;
beforeEach(async function () {
beforeStartTime = Date.now();
d.shouldValidateCaps = false;
await d.executeCommand('createSession', null, null, {
alwaysMatch: {...defaultCaps},
firstMatch: [{}],
});
});
describe('#eventHistory', function () {
it('should have an eventHistory property', function () {
should.exist(d.eventHistory);
should.exist(d.eventHistory.commands);
});
it('should have a session start timing after session start', function () {
let {newSessionRequested, newSessionStarted} = d.eventHistory;
newSessionRequested.should.have.length(1);
newSessionStarted.should.have.length(1);
newSessionRequested[0].should.be.a('number');
newSessionStarted[0].should.be.a('number');
(newSessionRequested[0] >= beforeStartTime).should.be.true;
(newSessionStarted[0] >= newSessionRequested[0]).should.be.true;
});
it('should include a commands list', async function () {
await d.executeCommand('getStatus', []);
d.eventHistory.commands.length.should.equal(2);
d.eventHistory.commands[1].cmd.should.equal('getStatus');
d.eventHistory.commands[1].startTime.should.be.a('number');
d.eventHistory.commands[1].endTime.should.be.a('number');
});
});
describe('#logEvent', function () {
it('should allow logging arbitrary events', function () {
d.logEvent('foo');
d.eventHistory.foo[0].should.be.a('number');
(d.eventHistory.foo[0] >= beforeStartTime).should.be.true;
});
it('should not allow reserved or oddly formed event names', function () {
(() => {
d.logEvent('commands');
}).should.throw();
(() => {
// @ts-expect-error
d.logEvent(1);
}).should.throw();
(() => {
// @ts-expect-error
d.logEvent({});
}).should.throw();
});
});
it('should allow logging the same event multiple times', function () {
d.logEvent('bar');
d.logEvent('bar');
d.eventHistory.bar.should.have.length(2);
d.eventHistory.bar[1].should.be.a('number');
(d.eventHistory.bar[1] >= d.eventHistory.bar[0]).should.be.true;
});
describe('getSession decoration', function () {
it('should decorate getSession response if opt-in cap is provided', async function () {
let res = await d.getSession();
should.not.exist(res.events);
_.set(d, 'caps.eventTimings', true);
res = await d.getSession();
should.exist(res.events);
should.exist(res.events?.newSessionRequested);
expect(res.events?.newSessionRequested[0]).to.be.a('number');
});
});
});
describe('.reset', function () {
it('should reset as W3C if the original session was W3C', async function () {
const caps = {
alwaysMatch: {
'appium:app': 'Fake',
'appium:deviceName': 'Fake',
'appium:automationName': 'Fake',
platformName: 'Fake',
...defaultCaps,
},
firstMatch: [{}],
};
await d.createSession(caps);
expect(d.protocol).to.equal('W3C');
await d.reset();
expect(d.protocol).to.equal('W3C');
});
});
});
describe('.isFeatureEnabled', function () {
let d;
beforeEach(function () {
d = new DriverClass();
});
it('should say a feature is enabled when it is explicitly allowed', function () {
d.allowInsecure = ['foo', 'bar'];
d.isFeatureEnabled('foo').should.be.true;
d.isFeatureEnabled('bar').should.be.true;
d.isFeatureEnabled('baz').should.be.false;
});
it('should say a feature is not enabled if it is not enabled', function () {
d.allowInsecure = [];
d.isFeatureEnabled('foo').should.be.false;
});
it('should prefer denyInsecure to allowInsecure', function () {
d.allowInsecure = ['foo', 'bar'];
d.denyInsecure = ['foo'];
d.isFeatureEnabled('foo').should.be.false;
d.isFeatureEnabled('bar').should.be.true;
d.isFeatureEnabled('baz').should.be.false;
});
it('should allow global setting for insecurity', function () {
d.relaxedSecurityEnabled = true;
d.isFeatureEnabled('foo').should.be.true;
d.isFeatureEnabled('bar').should.be.true;
d.isFeatureEnabled('baz').should.be.true;
});
it('global setting should be overrideable', function () {
d.relaxedSecurityEnabled = true;
d.denyInsecure = ['foo', 'bar'];
d.isFeatureEnabled('foo').should.be.false;
d.isFeatureEnabled('bar').should.be.false;
d.isFeatureEnabled('baz').should.be.true;
});
});
}
/**
* @typedef {import('@appium/types').DriverClass} DriverClass
* @typedef {import('@appium/types').W3CCapabilities} W3CCapabilities
* @typedef {import('@appium/types').AppiumW3CCapabilities} AppiumW3CCapabilities
*/

View File

@@ -0,0 +1,69 @@
{
"name": "@appium/driver-test-support",
"version": "0.1.0",
"description": "Test utilities for Appium drivers",
"keywords": [
"automation",
"javascript",
"selenium",
"webdriver",
"ios",
"android",
"firefoxos",
"testing"
],
"homepage": "https://appium.io",
"bugs": {
"url": "https://github.com/appium/appium/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/appium/appium.git",
"directory": "packages/driver-test-support"
},
"license": "Apache-2.0",
"author": "https://github.com/appium",
"main": "index.js",
"directories": {
"lib": "lib"
},
"files": [
"index.js",
"lib",
"build"
],
"scripts": {
"build": "babel lib --root-mode=upward --out-dir=build/lib",
"dev": "npm run build -- --watch",
"fix": "npm run lint -- --fix",
"lint": "eslint -c ../../.eslintrc --ignore-path ../../.eslintignore .",
"prepare": "npm run build",
"test": "npm run test:unit",
"test:smoke": "node ./index.js",
"test:unit": "mocha \"test/unit/**/*.spec.js\""
},
"types": "./build/lib/index.d.ts",
"dependencies": {
"@appium/types": "0.4.0",
"@babel/runtime": "7.18.9",
"@types/lodash": "4.14.184",
"axios": "0.27.2",
"bluebird": "3.7.2",
"chai": "4.3.6",
"get-port": "5.1.1",
"lodash": "4.17.21",
"sinon": "14.0.0",
"source-map-support": "0.5.21"
},
"peerDependencies": {
"appium": "^2.0.0-beta.43",
"mocha": "*"
},
"engines": {
"node": ">=14",
"npm": ">=8"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,41 @@
import {TEST_HOST, getTestPort, createAppiumURL} from '../../lib';
import _ from 'lodash';
const {expect} = chai;
describe('TEST_HOST', function () {
it('should be localhost', function () {
expect(TEST_HOST).to.equal('127.0.0.1');
});
});
describe('getTestPort()', function () {
it('should get a free test port', async function () {
const port = await getTestPort();
expect(port).to.be.a('number');
});
});
describe('createAppiumURL()', function () {
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);
});
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);
});
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);
});
});

View File

@@ -0,0 +1,20 @@
{
"extends": "../../config/tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "build",
"checkJs": true,
"paths": {
"@appium/types": ["../types"],
"appium/driver": ["../base-driver"],
},
"types": [
"mocha", "chai", "chai-as-promised"
]
},
"include": ["./lib"],
"references": [
{"path": "../types"},
{"path": "../base-driver"}
]
}

View File

@@ -4,6 +4,9 @@
"esModuleInterop": true
},
"references": [
{
"path": "packages/driver-test-support"
},
{
"path": "packages/schema"
},