kuben-appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
292 lines (243 loc) • 9.93 kB
JavaScript
import { iosCommands } from 'kuben-appium-ios-driver';
import { retryInterval } from 'asyncbox';
import { util } from 'appium-support';
import log from '../logger';
import _ from 'lodash';
import B from 'bluebird';
const IPHONE_EXTRA_WEB_COORD_SCROLL_OFFSET = -15;
const IPHONE_EXTRA_WEB_COORD_NON_SCROLL_OFFSET = 10;
const IPHONE_WEB_COORD_OFFSET = -10;
const IPHONE_WEB_COORD_SMART_APP_BANNER_OFFSET = 84;
const IPHONE_X_EXTRA_WEB_COORD_SCROLL_OFFSET = -90;
const IPHONE_X_EXTRA_WEB_COORD_NON_SCROLL_OFFSET = -10;
const IPHONE_X_WEB_COORD_OFFSET = 40;
const IPAD_EXTRA_WEB_COORD_SCROLL_OFFSET = -10;
const IPAD_EXTRA_WEB_COORD_NON_SCROLL_OFFSET = 0;
const IPAD_WEB_COORD_OFFSET = 10;
const IPAD_WEB_COORD_SMART_APP_BANNER_OFFSET = 95;
const IPHONE_X_WIDTH = 375;
const IPHONE_X_HEIGHT = 812;
const ATOM_WAIT_TIMEOUT = 5 * 60000;
let extensions = {};
Object.assign(extensions, iosCommands.web);
extensions.getSafariIsIphone = async function getSafariIsIphone () {
try {
const userAgent = await this.execute('return navigator.userAgent');
return userAgent.toLowerCase().includes('iphone');
} catch (err) {
log.warn(`Unable to find device type from useragent. Assuming iPhone`);
log.debug(`Error: ${err.message}`);
}
return true;
};
extensions.getSafariIsIphoneX = async function getSafariIsIphone () {
try {
const script = 'return {height: window.screen.availHeight, width: window.screen.availWidth};';
const {height, width} = await this.execute(script);
// check for the correct height and width
return (height === IPHONE_X_HEIGHT && width === IPHONE_X_WIDTH) ||
(height === IPHONE_X_WIDTH && width === IPHONE_X_HEIGHT);
} catch (err) {
log.warn(`Unable to find device type from useragent. Assuming not iPhone X`);
log.debug(`Error: ${err.message}`);
}
return false;
};
const getElementHeightMemoized = _.memoize(async function (key, driver, el) {
el = util.unwrapElement(el);
return (await driver.getNativeRect(el)).height;
});
extensions.getExtraTranslateWebCoordsOffset = async function (coords, webviewRect) {
let offset = 0;
// keep track of implicit wait, and set locally to 0
const implicitWaitMs = this.implicitWaitMs;
const isIphone = await this.getSafariIsIphone();
const isIphoneX = isIphone && await this.getSafariIsIphoneX();
try {
this.setImplicitWait(0);
// check if the full url bar is up
await this.findNativeElementOrElements('accessibility id', 'ReloadButton', false);
// reload button found, which means scrolling has not happened
if (isIphoneX) {
offset += IPHONE_X_EXTRA_WEB_COORD_NON_SCROLL_OFFSET;
} else if (isIphone) {
offset += IPHONE_EXTRA_WEB_COORD_NON_SCROLL_OFFSET;
} else {
offset += IPAD_EXTRA_WEB_COORD_NON_SCROLL_OFFSET;
}
} catch (err) {
// no reload button, which indicates scrolling has happened
// the URL bar may or may not be visible
try {
const el = await this.findNativeElementOrElements('accessibility id', 'URL', false);
offset -= await getElementHeightMemoized('URLBar', this, el);
} catch (ign) {
// no URL elements found, so continue
}
} finally {
// return implicit wait to what it was
this.setImplicitWait(implicitWaitMs);
}
if (coords.y > webviewRect.height) {
// when scrolling has happened, there is a tick more offset needed
if (isIphoneX) {
offset += IPHONE_X_EXTRA_WEB_COORD_SCROLL_OFFSET;
} else if (isIphone) {
offset += IPHONE_EXTRA_WEB_COORD_SCROLL_OFFSET;
} else {
offset += IPAD_EXTRA_WEB_COORD_SCROLL_OFFSET;
}
}
// extra offset necessary
offset += isIphone ? IPHONE_WEB_COORD_OFFSET : IPAD_WEB_COORD_OFFSET;
offset += isIphoneX ? IPHONE_X_WEB_COORD_OFFSET : 0;
log.debug(`Extra translated web coordinates offset: ${offset}`);
return offset;
};
extensions.getExtraNativeWebTapOffset = async function () {
let offset = 0;
// keep track of implicit wait, and set locally to 0
const implicitWaitMs = this.implicitWaitMs;
try {
this.setImplicitWait(0);
// first try to get tab offset
try {
const el = await this.findNativeElementOrElements('-ios predicate string', `name LIKE '*, Tab' AND visible = 1`, false);
offset += await getElementHeightMemoized('TabBar', this, el);
} catch (ign) {
// no element found, so no tabs and no need to deal with offset
}
// next try to see if there is an Smart App Banner
try {
await this.findNativeElementOrElements('accessibility id', 'Close app download offer', false);
offset += await this.getSafariIsIphone() ?
IPHONE_WEB_COORD_SMART_APP_BANNER_OFFSET :
IPAD_WEB_COORD_SMART_APP_BANNER_OFFSET;
} catch (ign) {
// no smart app banner found, so continue
}
} finally {
// return implicit wait to what it was
this.setImplicitWait(implicitWaitMs);
}
log.debug(`Additional native web tap offset computed: ${offset}`);
return offset;
};
async function tapWebElementNatively (driver, atomsElement) {
// try to get the text of the element, which will be accessible in the
// native context
try {
let text = await driver.executeAtom('get_text', [atomsElement]);
if (!text) {
text = await driver.executeAtom('get_attribute_value', [atomsElement, 'value']);
}
if (text) {
const el = await driver.findNativeElementOrElements('accessibility id', text, false);
// use tap because on iOS 11.2 and below `nativeClick` crashes WDA
const rect = await driver.proxyCommand(`/element/${el.ELEMENT}/rect`, 'GET');
const coords = {
x: Math.round(rect.x + rect.width / 2),
y: Math.round(rect.y + rect.height / 2),
};
await driver.clickCoords(coords);
return true;
}
} catch (err) {
// any failure should fall through and trigger the more elaborate
// method of clicking
log.warn(`Error attempting to click: ${err.message}`);
}
return false;
}
extensions.nativeWebTap = async function (el) {
const atomsElement = this.useAtomsElement(el);
if (await tapWebElementNatively(this, atomsElement)) {
return;
}
log.warn('Unable to do simple native web tap. Attempting to convert coordinates');
// `get_top_left_coordinates` returns the wrong value sometimes,
// unless we pre-call both of these functions before the actual calls
await this.executeAtom('get_size', [atomsElement]);
await this.executeAtom('get_top_left_coordinates', [atomsElement]);
const {width, height} = await this.executeAtom('get_size', [atomsElement]);
let {x, y} = await this.executeAtom('get_top_left_coordinates', [atomsElement]);
x += width / 2;
y += height / 2;
this.curWebCoords = {x, y};
await this.clickWebCoords();
};
extensions.clickCoords = async function (coords) {
await this.performTouch([
{
action: 'tap',
options: coords,
},
]);
};
extensions.translateWebCoords = async function (coords) {
log.debug(`Translating coordinates (${JSON.stringify(coords)}) to web coordinates`);
// absolutize web coords
const implicitWaitMs = this.implicitWaitMs;
let webview;
try {
this.setImplicitWait(0);
webview = await retryInterval(5, 100, async () => {
return await this.findNativeElementOrElements('-ios predicate string', `type = 'XCUIElementTypeWebView' AND visible = 1`, false);
});
} finally {
this.setImplicitWait(implicitWaitMs);
}
webview = util.unwrapElement(webview);
const rect = await this.proxyCommand(`/element/${webview}/rect`, 'GET');
const wvPos = {x: rect.x, y: rect.y};
const realDims = {w: rect.width, h: rect.height};
const cmd = '(function () { return {w: document.documentElement.clientWidth, h: document.documentElement.clientHeight}; })()';
const wvDims = await this.remote.execute(cmd);
// TODO: investigate where these come from. They appear to be constants in my tests
const urlBarHeight = 64;
wvPos.y += urlBarHeight;
const realDimensionHeight = 108;
realDims.h -= realDimensionHeight;
// add static offset for safari in landscape mode
let yOffset = this.opts.curOrientation === 'LANDSCAPE' ? this.landscapeWebCoordsOffset : 0;
// add extra offset for possible extra things in the top of the page
yOffset += await this.getExtraNativeWebTapOffset();
coords.y += await this.getExtraTranslateWebCoordsOffset(coords, rect);
if (wvDims && realDims && wvPos) {
let xRatio = realDims.w / wvDims.w;
let yRatio = realDims.h / wvDims.h;
let newCoords = {
x: wvPos.x + Math.round(xRatio * coords.x),
y: wvPos.y + yOffset + Math.round(yRatio * coords.y),
};
// additional logging for coordinates, since it is sometimes broken
// see https://github.com/appium/appium/issues/9159
log.debug(`Converted coordinates: ${JSON.stringify(newCoords)}`);
log.debug(` rect: ${JSON.stringify(rect)}`);
log.debug(` wvPos: ${JSON.stringify(wvPos)}`);
log.debug(` realDims: ${JSON.stringify(realDims)}`);
log.debug(` wvDims: ${JSON.stringify(wvDims)}`);
log.debug(` xRatio: ${JSON.stringify(xRatio)}`);
log.debug(` yRatio: ${JSON.stringify(yRatio)}`);
log.debug(` yOffset: ${JSON.stringify(yOffset)}`);
log.debug(`Converted web coords ${JSON.stringify(coords)} ` +
`into real coords ${JSON.stringify(newCoords)}`);
return newCoords;
}
};
extensions.checkForAlert = async function () { // eslint-disable-line require-await
return false;
};
extensions.waitForAtom = async function (promise) {
const started = process.hrtime();
try {
return this.parseExecuteResponse(await B.resolve(promise)
.timeout(ATOM_WAIT_TIMEOUT));
} catch (err) {
if (err instanceof B.TimeoutError) {
throw new Error(`Did not get any response after ${process.hrtime(started)[0]}s`);
}
throw err;
}
};
export default extensions;