appium-xcuitest-driver-conan
Version:
Appium driver for iOS using XCUITest for backend
541 lines (478 loc) • 18.3 kB
JavaScript
import { errors } from 'appium-base-driver';
import { util } from 'appium-support';
import { iosCommands } from 'appium-ios-driver';
import _ from 'lodash';
import log from '../logger';
let helpers = {}, extensions = {}, commands = {};
commands.moveTo = iosCommands.gesture.moveTo;
commands.click = async function (el) {
if (!this.isWebContext()) {
// there are multiple commands that map here, so manually proxy
return await this.nativeClick(el);
}
el = util.unwrapElement(el);
if (this.opts.nativeWebTap && !this.isRealDevice()) {
// atoms-based clicks don't always work in safari 7
await this.nativeWebTap(el);
} else {
let atomsElement = this.useAtomsElement(el);
return await this.executeAtom('click', [atomsElement]);
}
};
function isSameGestures (gestures, candidates) {
try {
if (gestures.length !== candidates.length) {
return false;
}
for (let i = 0; i < gestures.length; i++) {
const gestureObj = gestures[i];
const candidateObj = candidates[i];
if (!_.isPlainObject(gestureObj) || !_.isPlainObject(candidateObj)) {
return false;
}
if (_.difference(_.keys(candidateObj), _.keys(gestureObj)).length) {
return false;
}
if (gestureObj.action.toLowerCase() !== candidateObj.action.toLowerCase()) {
return false;
}
if (candidateObj.options && gestureObj.options.count !== candidateObj.options.count) {
return false;
}
}
} catch (err) {
log.debug(`Error "${err.message}" while comparing gestures. Considering them as not equal`);
return false;
}
return true;
}
function gesturesChainToString (gestures, keysToInclude = ['options']) {
return gestures.map((item) => {
let otherKeys = _.difference(_.keys(item), ['action']);
otherKeys = _.isArray(keysToInclude) ? _.intersection(otherKeys, keysToInclude) : otherKeys;
if (otherKeys.length) {
return `${item.action}` +
`(${_.map(otherKeys, (x) => x + '=' + (_.isPlainObject(item[x]) ? JSON.stringify(item[x]) : item[x])).join(', ')})`;
}
return item.action;
}).join('-');
}
commands.performTouch = async function (gestures) {
log.debug(`Received the following touch action: ${gesturesChainToString(gestures)}`);
const supportedGesturesMapping = {
doubleTap: {
handler: async (x) => {await this.handleDoubleTap(x);},
matches: [
[{action: 'doubletap'}],
[{action: 'tap', options: {count: 2}}]
]
},
tap: {
handler: async (x) => {await this.handleTap(x[0]);},
matches: [
[{action: 'tap'}],
[{action: 'tap'}, {action: 'release'}],
[{action: 'press'}, {action: 'release'}]
]
},
longPress: {
handler: async (x) => {await this.handleLongPress(x);},
matches: [
[{action: 'longpress'}],
[{action: 'press'}, {action: 'wait'}, {action: 'release'}]
]
},
drag: {
handler: async (x) => {await this.handleDrag(x);},
matches: [
[{action: 'press'}, {action: 'wait'}, {action: 'moveTo'}, {action: 'release'}]
]
},
scroll: {
handler: async (x) => {await this.handleScroll(x);},
matches: [
[{action: 'press'}, {action: 'moveTo'}, {action: 'release'}]
]
}
};
for (let [cmd, info] of _.toPairs(supportedGesturesMapping)) {
for (let candidateMatch of info.matches) {
if (isSameGestures(gestures, candidateMatch)) {
log.debug(`Found matching gesture: ${cmd}`);
return await info.handler(gestures);
}
}
}
let availableGestures = '';
for (let [cmd, info] of _.toPairs(supportedGesturesMapping)) {
availableGestures += `\t${cmd}: `;
for (let candidateMatch of info.matches) {
availableGestures += `\t\t${gesturesChainToString(candidateMatch)}\n`;
}
}
throw new errors.NotYetImplementedError(`Support for ${gesturesChainToString(gestures)} gesture is not implemented. ` +
`Try to use "mobile: *" interface to workaround the issue. ` +
`Only these gestures are supported:\n${availableGestures}`);
};
commands.performMultiAction = async function (actions) {
log.debug(`Received the following multi touch action:`);
for (let i in actions) {
log.debug(` ${i+1}: ${_.map(actions[i], 'action').join('-')}`);
}
if (isPinchOrZoom(actions)) {
return await this.handlePinchOrZoom(actions);
}
throw new errors.NotYetImplementedError('Support for this multi-action is not implemented. Try to use "mobile: *" interface to workaround the issue.');
};
commands.nativeClick = async function (el) {
el = util.unwrapElement(el);
let endpoint = `/element/${el}/click`;
return await this.proxyCommand(endpoint, 'POST', {});
};
function isScroll (gestures) {
if (gestures.length === 3 &&
gestures[0].action === 'press' &&
gestures[1].action === 'moveTo' &&
gestures[2].action === 'release') {
return true;
}
return false;
}
function isPinchOrZoom (actions = []) {
// symmetric two-finger action consisting of press-moveto-release
if (actions.length === 2) {
if (actions[0].length === 3 && actions[1].length === 3) {
return _.every(actions, (gestures) => isScroll(gestures));
}
}
return false;
}
helpers.handleScroll = async function (gestures) {
if (gestures[1].options.element) {
// use the to-visible option of scrolling in WDA
return await this.mobileScroll({
element: gestures[1].options.element,
toVisible: true
});
}
// otherwise, for now, just translate into a drag with short duration
let dragGestures = [
gestures[0],
{action: 'wait', options: {ms: 0}},
gestures[1],
gestures[2]
];
return await this.handleDrag(dragGestures);
};
helpers.handleDrag = async function (gestures) {
// get gestures
let press = gestures[0];
let wait = gestures[1];
let moveTo = gestures[2];
// get drag data
let pressCoordinates = await this.getCoordinates(press);
let duration = (parseInt(wait.options.ms, 10) / 1000);
let moveToCoordinates = await this.getCoordinates(moveTo);
// update moveTo coordinates with offset
moveToCoordinates = this.applyMoveToOffset(pressCoordinates, moveToCoordinates);
// build drag command
let params = {};
params.fromX = pressCoordinates.x;
params.fromY = pressCoordinates.y;
params.toX = moveToCoordinates.x;
params.toY = moveToCoordinates.y;
params.duration = duration;
let endpoint = `/wda/dragfromtoforduration`;
return await this.proxyCommand(endpoint, 'POST', params);
};
helpers.handleTap = async function (gesture) {
let options = gesture.options || {};
let params = {};
if (util.hasValue(options.x) && util.hasValue(options.y)) {
params.x = options.x;
params.y = options.y;
}
let el = util.hasValue(options.element) ? options.element : '0';
let endpoint = `/wda/tap/${el}`;
if (util.hasValue(this.opts.tapWithShortPressDuration)) {
// in some cases `tap` is too slow, so allow configurable long press
log.debug(`Translating tap into long press with '${this.opts.tapWithShortPressDuration}' duration`);
params.duration = parseFloat(this.opts.tapWithShortPressDuration);
endpoint = `/wda/element/${el}/touchAndHold`;
params.duration = parseFloat(this.opts.tapWithShortPressDuration);
}
return await this.proxyCommand(endpoint, 'POST', params);
};
helpers.handleDoubleTap = async function (gestures) {
let gesture = gestures[0];
let opts = gesture.options || {};
if (!opts.element) {
log.errorAndThrow('WDA double tap needs an element');
}
let el = util.unwrapElement(opts.element);
let endpoint = `/wda/element/${el}/doubleTap`;
return await this.proxyCommand(endpoint, 'POST');
};
helpers.handleLongPress = async function (gestures) {
let pressOpts = gestures[0].options || {};
let el = util.unwrapElement(pressOpts.element);
let duration; // In seconds (not milliseconds)
if (gestures.length === 1 && util.hasValue(pressOpts.duration)) {
duration = pressOpts.duration / 1000;
} else if (gestures.length === 3) {
// duration is the `wait` action
// upstream system expects seconds not milliseconds
duration = parseFloat(gestures[1].options.ms) / 1000;
} else {
// give a sane default duration
duration = 0.8;
}
let params = {
duration,
x: pressOpts.x,
y: pressOpts.y,
};
let endpoint;
if (el) {
endpoint = `/wda/element/${el}/touchAndHold`;
} else {
params.x = pressOpts.x;
params.y = pressOpts.y;
endpoint = '/wda/touchAndHold';
}
return await this.proxyCommand(endpoint, 'POST', params);
};
function determinePinchScale (x, y, pinch) {
let scale = x > y ? x - y : y - x;
if (pinch) {
// TODO: revisit this when pinching actually works, since it is impossible to
// know what the scale factor does at this point (Xcode 8.1)
scale = 1 / scale;
if (scale < 0.02) {
// this is the minimum that Apple will allow
// but WDA will not throw an error if it is too low
scale = 0.02;
}
} else {
// for zoom, each 10px is one scale factor
scale = scale / 10;
}
return scale;
}
helpers.handlePinchOrZoom = async function (actions) {
// currently we can only do this action on an element
if (!actions[0][0].options.element ||
actions[0][0].options.element !== actions[1][0].options.element) {
log.errorAndThrow('Pinch/zoom actions must be done on a single element');
}
let el = actions[0][0].options.element;
// assume that action is in a single plane (x or y, not horizontal at all)
// terminology all assuming right handedness
let scale, velocity;
if (actions[0][0].options.y === actions[0][1].options.y) {
// horizontal, since y offset is the same in press and moveTo
let thumb = (actions[0][0].options.x <= actions[1][0].options.x) ? actions[0] : actions[1];
// now decipher pinch vs. zoom,
// pinch: thumb moving from left to right
// zoom: thumb moving from right to left
scale = determinePinchScale(thumb[0].options.x, thumb[1].options.x, thumb[0].options.x <= thumb[1].options.x);
} else {
// vertical
let forefinger = (actions[0][0].options.y <= actions[1][0].options.y) ? actions[0] : actions[1];
// now decipher pinch vs. zoom
// pinch: forefinger moving from top to bottom
// zoom: forefinger moving from bottom to top
scale = determinePinchScale(forefinger[0].options.y, forefinger[1].options.y, forefinger[0].options.y <= forefinger[1].options.y);
}
velocity = scale < 1 ? -1 : 1;
log.debug(`Decoded ${scale < 1 ? 'pinch' : 'zoom'} action with scale '${scale}' and velocity '${velocity}'`);
if (scale < 1) {
log.warn('Pinch actions may not work, due to Apple issue.');
}
let params = {
scale,
velocity
};
await this.proxyCommand(`/wda/element/${el}/pinch`, 'POST', params);
};
/*
* See https://github.com/facebook/WebDriverAgent/blob/master/WebDriverAgentLib/Commands/FBElementCommands.m
* to get the info about available WDA gestures API
*
* See https://developer.apple.com/reference/xctest/xcuielement and
* https://developer.apple.com/reference/xctest/xcuicoordinate to get the detailed description of
* all XCTest gestures
*/
helpers.mobileScroll = async function (opts={}, swipe=false) {
if (!opts.element) {
opts.element = await this.findNativeElementOrElements(`class name`, `XCUIElementTypeApplication`, false);
}
// WDA supports four scrolling strategies: predication based on name, direction,
// predicateString, and toVisible, in that order. Swiping requires direction.
let params = {};
if (opts.name && !swipe) {
params.name = opts.name;
} else if (opts.direction) {
if (['up', 'down', 'left', 'right'].indexOf(opts.direction.toLowerCase()) < 0) {
let msg = 'Direction must be up, down, left or right';
log.errorAndThrow(msg);
}
params.direction = opts.direction;
} else if (opts.predicateString && !swipe) {
params.predicateString = opts.predicateString;
} else if (opts.toVisible && !swipe) {
params.toVisible = opts.toVisible;
} else {
let msg = swipe ? 'Mobile swipe requires direction' : 'Mobile scroll supports the following strategies: name, direction, predicateString, and toVisible. Specify one of these';
log.errorAndThrow(msg);
}
let element = opts.element.ELEMENT || opts.element;
let endpoint = `/wda/element/${element}/${swipe ? 'swipe' : 'scroll'}`;
return await this.proxyCommand(endpoint, 'POST', params);
};
function parseFloatParameter (paramName, paramValue, methodName) {
if (_.isUndefined(paramValue)) {
log.errorAndThrow(`"${paramName}" parameter is mandatory for "${methodName}" call`);
}
const result = parseFloat(paramValue);
if (isNaN(result)) {
log.errorAndThrow(`"${paramName}" parameter should be a valid number. "${paramValue}" is given instead`);
}
return result;
}
helpers.mobilePinch = async function (opts={}) {
if (!opts.element) {
opts.element = await this.findNativeElementOrElements(`class name`, `XCUIElementTypeApplication`, false);
}
const params = {
scale: parseFloatParameter('scale', opts.scale, 'pinch'),
velocity: parseFloatParameter('velocity', opts.velocity, 'pinch')
};
const el = opts.element.ELEMENT || opts.element;
return await this.proxyCommand(`/wda/element/${el}/pinch`, 'POST', params);
};
helpers.mobileDoubleTap = async function (opts={}) {
if (opts.element) {
// Double tap element
const el = opts.element.ELEMENT || opts.element;
return await this.proxyCommand(`/wda/element/${el}/doubleTap`, 'POST');
}
// Double tap coordinates
const params = {
x: parseFloatParameter('x', opts.x, 'doubleTap'),
y: parseFloatParameter('y', opts.y, 'doubleTap')
};
return await this.proxyCommand('/wda/doubleTap', 'POST', params);
};
helpers.mobileTwoFingerTap = async function (opts={}) {
if (!opts.element) {
opts.element = await this.findNativeElementOrElements(`class name`, `XCUIElementTypeApplication`, false);
}
const el = opts.element.ELEMENT || opts.element;
return await this.proxyCommand(`/wda/element/${el}/twoFingerTap`, 'POST');
};
helpers.mobileTouchAndHold = async function (opts={}) {
let params = {
duration: parseFloatParameter('duration', opts.duration, 'touchAndHold')
};
if (opts.element) {
// Long tap element
const el = opts.element.ELEMENT || opts.element;
return await this.proxyCommand(`/wda/element/${el}/touchAndHold`, 'POST', params);
}
// Long tap coordinates
params.x = parseFloatParameter('x', opts.x, 'touchAndHold');
params.y = parseFloatParameter('y', opts.y, 'touchAndHold');
return await this.proxyCommand('/wda/touchAndHold', 'POST', params);
};
helpers.mobileTap = async function (opts={}) {
const params = {
x: parseFloatParameter('x', opts.x, 'tap'),
y: parseFloatParameter('y', opts.y, 'tap')
};
const el = opts.element ? (opts.element.ELEMENT || opts.element) : '0';
return await this.proxyCommand(`/wda/tap/${el}`, 'POST', params);
};
helpers.mobileDragFromToForDuration = async function (opts={}) {
const params = {
duration: parseFloatParameter('duration', opts.duration, 'dragFromToForDuration'),
fromX: parseFloatParameter('fromX', opts.fromX, 'dragFromToForDuration'),
fromY: parseFloatParameter('fromY', opts.fromY, 'dragFromToForDuration'),
toX: parseFloatParameter('toX', opts.toX, 'dragFromToForDuration'),
toY: parseFloatParameter('toY', opts.toY, 'dragFromToForDuration')
};
if (opts.element) {
// Drag element
const el = opts.element.ELEMENT || opts.element;
return await this.proxyCommand(`/wda/element/${el}/dragfromtoforduration`, 'POST', params);
}
// Drag coordinates
return await this.proxyCommand('/wda/dragfromtoforduration', 'POST', params);
};
helpers.mobileSelectPickerWheelValue = async function (opts={}) {
if (!opts.element) {
log.errorAndThrow('Element id is expected to be set for selectPickerWheelValue method');
}
if (!_.isString(opts.order) || ['next', 'previous'].indexOf(opts.order.toLowerCase()) === -1) {
log.errorAndThrow(`The mandatory "order" parameter is expected to be equal either to 'next' or 'previous'. ` +
`'${opts.order}' is given instead`);
}
const el = opts.element.ELEMENT || opts.element;
const params = {order: opts.order};
if (opts.offset) {
params.offset = parseFloatParameter('offset', opts.offset, 'selectPickerWheelValue');
}
return await this.proxyCommand(`/wda/pickerwheel/${el}/select`, 'POST', params);
};
helpers.getCoordinates = async function (gesture) {
let el = gesture.options.element;
// defaults
let coordinates = {x: 0, y: 0, areOffsets: false};
let optionX = null;
if (gesture.options.x) {
optionX = parseFloatParameter('x', gesture.options.x, 'getCoordinates');
}
let optionY = null;
if (gesture.options.y) {
optionY = parseFloatParameter('y', gesture.options.y, 'getCoordinates');
}
// figure out the element coordinates.
if (el) {
let rect = await this.getRect(el);
let pos = {x: rect.x, y: rect.y};
let size = {w: rect.width, h: rect.height};
// defaults
let offsetX = 0;
let offsetY = 0;
// get the real offsets
if (optionX || optionY) {
offsetX = (optionX || 0);
offsetY = (optionY || 0);
} else {
offsetX = (size.w / 2);
offsetY = (size.h / 2);
}
// apply the offsets
coordinates.x = pos.x + offsetX;
coordinates.y = pos.y + offsetY;
} else {
// moveTo coordinates are passed in as offsets
coordinates.areOffsets = (gesture.action === 'moveTo');
coordinates.x = (optionX || 0);
coordinates.y = (optionY || 0);
}
return coordinates;
};
helpers.applyMoveToOffset = function (firstCoordinates, secondCoordinates) {
if (secondCoordinates.areOffsets) {
return {
x: firstCoordinates.x + secondCoordinates.x,
y: firstCoordinates.y + secondCoordinates.y,
};
} else {
return secondCoordinates;
}
};
Object.assign(extensions, helpers, commands);
export { extensions, helpers, commands, isSameGestures, gesturesChainToString };
export default extensions;