gst-atom-xcuitest-driver
Version:
ATOM driver for iOS using XCUITest for backend
331 lines (279 loc) • 11.3 kB
JavaScript
import { iosCommands } from 'gst-atom-ios-driver';
import { retryInterval, waitForCondition } from 'asyncbox';
import { util, timing } from 'appium-support';
import log from '../logger';
import _ from 'lodash';
import B from 'bluebird';
const IPHONE_TOP_BAR_HEIGHT = 71;
const IPHONE_SCROLLED_TOP_BAR_HEIGHT = 41;
const IPHONE_X_SCROLLED_OFFSET = 55;
const IPHONE_X_NOTCH_OFFSET_IOS = 24;
const IPHONE_X_NOTCH_OFFSET_IOS_13 = 20;
const IPHONE_LANDSCAPE_TOP_BAR_HEIGHT = 51;
const IPHONE_BOTTOM_BAR_OFFSET = 49;
const TAB_BAR_OFFSET = 33;
const IPHONE_WEB_COORD_SMART_APP_BANNER_OFFSET = 84;
const IPAD_WEB_COORD_SMART_APP_BANNER_OFFSET = 95;
const NOTCHED_DEVICE_SIZES = [
{w: 1125, h: 2436}, // 11 Pro, X, Xs
{w: 828, h: 1792}, // 11, Xr
{w: 1242, h: 2688}, // 11 Pro Max, Xs Max
];
const ATOM_WAIT_TIMEOUT = 2 * 60000;
const ATOM_WAIT_ALERT_WAIT = 400;
let extensions = {};
Object.assign(extensions, iosCommands.web);
extensions.getSafariIsIphone = _.memoize(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.getSafariDeviceSize = _.memoize(async function getSafariDeviceSize () {
const script = 'return {height: window.screen.availHeight * window.devicePixelRatio, width: window.screen.availWidth * window.devicePixelRatio};';
const {width, height} = await this.execute(script);
const [normHeight, normWidth] = height > width ? [height, width] : [width, height];
return {
width: normWidth,
height: normHeight,
};
});
extensions.getSafariIsNotched = _.memoize(async function getSafariIsNotched () {
try {
const {width, height} = await this.getSafariDeviceSize();
for (const device of NOTCHED_DEVICE_SIZES) {
if (device.w === width && device.h === height) {
return true;
}
}
} catch (err) {
log.warn(`Unable to find device type from dimensions. Assuming the device is not notched`);
log.debug(`Error: ${err.message}`);
}
return false;
});
extensions.getExtraTranslateWebCoordsOffset = async function getExtraTranslateWebCoordsOffset (wvPos, realDims) {
let topOffset = 0;
let bottomOffset = 0;
const isIphone = await this.getSafariIsIphone();
const isNotched = isIphone && await this.getSafariIsNotched();
const orientation = realDims.h > realDims.w ? 'PORTRAIT' : 'LANDSCAPE';
const notchOffset = isNotched
? util.compareVersions(this.opts.platformVersion, '=', '13.0')
? IPHONE_X_NOTCH_OFFSET_IOS_13
: IPHONE_X_NOTCH_OFFSET_IOS
: 0;
const isScrolled = await this.execute('return document.documentElement.scrollTop > 0');
if (isScrolled) {
topOffset = IPHONE_SCROLLED_TOP_BAR_HEIGHT + notchOffset;
if (isNotched) {
topOffset -= IPHONE_X_SCROLLED_OFFSET;
}
// If the iPhone is landscape then there is no top bar
if (orientation === 'LANDSCAPE' && isIphone) {
topOffset = 0;
}
} else {
topOffset = IPHONE_TOP_BAR_HEIGHT + notchOffset;
if (isIphone) {
if (orientation === 'PORTRAIT') {
// The bottom bar is only visible when portrait
bottomOffset = IPHONE_BOTTOM_BAR_OFFSET;
} else {
topOffset = IPHONE_LANDSCAPE_TOP_BAR_HEIGHT;
}
}
if (orientation === 'LANDSCAPE' || !isIphone) {
// Tabs only appear if the device is landscape or if it's an iPad so we only check visibility in this case
const tabs = await this.findNativeElementOrElements('-ios predicate string', `name LIKE '*, Tab' AND visible = 1`, true);
if (tabs.length > 0) {
topOffset += TAB_BAR_OFFSET;
}
}
}
topOffset += await this.getExtraNativeWebTapOffset();
wvPos.y += topOffset;
realDims.h -= (topOffset + bottomOffset);
};
extensions.getExtraNativeWebTapOffset = async function getExtraNativeWebTapOffset () {
let offset = 0;
// try to see if there is an Smart App Banner
const banners = await this.findNativeElementOrElements('accessibility id', 'Close app download offer', true);
if (banners.length > 0) {
offset += await this.getSafariIsIphone() ?
IPHONE_WEB_COORD_SMART_APP_BANNER_OFFSET :
IPAD_WEB_COORD_SMART_APP_BANNER_OFFSET;
}
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 els = await driver.findNativeElementOrElements('accessibility id', text, true);
if (els.length === 1 || els.length === 2) {
const el = els[0];
// use tap because on iOS 11.2 and below `nativeClick` crashes WDA
const rect = await driver.proxyCommand(`/element/${util.unwrapElement(el)}/rect`, 'GET');
if (els.length === 2) {
const el2 = els[1];
const rect2 = await driver.proxyCommand(`/element/${util.unwrapElement(el2)}/rect`, 'GET');
if ((rect.x !== rect2.x || rect.y !== rect2.y) ||
(rect.width !== rect2.width || rect.height !== rect2.height)) {
// These 2 native elements are not referring to the same web element
return false;
}
}
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 nativeWebTap (el) {
const atomsElement = this.useAtomsElement(el);
// if strict native tap, do not try to do it with WDA directly
if (!(await this.settings.getSettings()).nativeWebTapStrict && 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 clickCoords (coords) {
await this.performTouch([
{
action: 'tap',
options: coords,
},
]);
};
extensions.translateWebCoords = async function translateWebCoords (coords) {
log.debug(`Translating coordinates (${JSON.stringify(coords)}) to web coordinates`);
// absolutize web coords
let webview = await retryInterval(5, 100, async () => {
const webviews = await this.findNativeElementOrElements('class name', 'XCUIElementTypeWebView', true);
if (webviews.length === 0) {
throw new Error(`No webviews found. Unable to translate web coordinates for native web tap`);
}
return webviews[0];
});
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: window.innerWidth, h: window.innerHeight}; })()';
const wvDims = await this.remote.execute(cmd);
await this.getExtraTranslateWebCoordsOffset(wvPos, realDims);
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 + 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(`Converted web coords ${JSON.stringify(coords)} ` +
`into real coords ${JSON.stringify(newCoords)}`);
return newCoords;
}
};
extensions.checkForAlert = async function checkForAlert () {
return _.isString(await this.getAlertText());
};
extensions.waitForAtom = async function waitForAtom (promise) {
const timer = new timing.Timer().start();
// need to check for alert while the atom is being executed.
// so notify ourselves when it happens
let done = false;
let error = null;
promise = B.resolve(promise) // eslint-disable-line promise/catch-or-return
.timeout(ATOM_WAIT_TIMEOUT)
.catch(function (err) { // eslint-disable-line promise/prefer-await-to-callbacks
log.debug(`Error received while executing atom: ${err.message}`);
if (err instanceof B.TimeoutError) {
err = new Error(`Did not get any response for atom execution after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
}
// save and check later, or an Unhandled rejection will be reported
error = err;
})
.finally(function () {
done = true;
});
// try ten times to check alert
for (let i = 0; i < 10; i++) {
// pause, or the atom promise is resolved
try {
await waitForCondition(() => done, {
waitMs: ATOM_WAIT_ALERT_WAIT,
intervalMs: 0, // just for the pause in execution
});
// `done` became true, so atom promise is resolved
break;
} catch (ign) {
// `done` never became true, so move on to trying to find an alert
}
// check if there is an alert, or the atom promise is resolved
try {
const res = await B.any([this.checkForAlert(), promise]);
if (error) {
throw error;
}
return this.parseExecuteResponse(res);
} catch (err) {
// no alert found, so pass through
log.debug(`No alert found: ${err.message}`);
}
}
// at this point, all that can be done is wait for the atom promise to be
// resolved
const res = await promise;
if (error) {
throw error;
}
return this.parseExecuteResponse(res);
};
extensions.mobileWebNav = async function mobileWebNav (navType) {
this.remote.allowNavigationWithoutReload = true;
try {
await this.executeAtom('execute_script', [`history.${navType}();`, null]);
} finally {
this.remote.allowNavigationWithoutReload = false;
}
};
export default extensions;