gst-atom-xcuitest-driver
Version:
ATOM driver for iOS using XCUITest for backend
467 lines (405 loc) • 15.8 kB
JavaScript
import { iosCommands, IOSPerformanceLog, NATIVE_WIN, WEBVIEW_WIN } from 'gst-atom-ios-driver';
import { createRemoteDebugger, RemoteDebugger } from 'gst-atom-remote-debugger';
import { errors, isErrorType } from 'gst-atom-base-driver';
import { util, timing } from 'appium-support';
import log from '../logger';
import { retryInterval } from 'asyncbox';
import _ from 'lodash';
const WEBVIEW_BASE = `${WEBVIEW_WIN}_`;
let commands = {}, helpers = {}, extensions = {};
Object.assign(extensions, iosCommands.context);
// override, as gst-atom-ios-driver's version uses UI Automation to close
extensions.closeAlertBeforeTest = async function closeAlertBeforeTest () { // eslint-disable-line require-await
return true;
};
// the gst-atom-ios-driver version has a wait on real devices, which is no longer
// necessary
extensions.navToInitialWebview = async function navToInitialWebview () {
if (this.useNewSafari()) {
await this.typeAndNavToUrl();
} else if (!this.isRealDevice() && this.opts.safari) {
await this.navToViewThroughFavorites();
} else {
await this.navToViewWithTitle(/.*/);
}
};
// the gst-atom-ios-driver version of this function fails in CI,
// and the wrong webview is almost always retrieved
// also override so that the case where the SDK version is not set does not fail
extensions.getLatestWebviewContextForTitle = async function getLatestWebviewContextForTitle (regExp) {
const currentUrl = this.getCurrentUrl();
const contexts = _.filter(await this.getContextsAndViews(), 'view');
if (currentUrl) {
// first try to match by current url
for (const ctx of contexts) {
if ((ctx.view.url || '') === this.getCurrentUrl()) {
return ctx.id;
}
}
}
// if not, try to match by regular expression
for (const ctx of contexts) {
if ((ctx.view.title && regExp.test(ctx.view.title)) || (ctx.view.url && regExp.test(ctx.view.url))) {
return ctx.id;
}
}
};
extensions.isWebContext = function isWebContext () {
return !!this.curContext && this.curContext !== iosCommands.context.NATIVE_WIN;
};
extensions.isWebview = function isWebview () {
return this.isWebContext();
};
extensions.getNewRemoteDebugger = async function getNewRemoteDebugger () {
let socketPath;
if (!this.isRealDevice()) {
socketPath = await this.opts.device.getWebInspectorSocket();
}
return createRemoteDebugger({
bundleId: this.opts.bundleId,
additionalBundleIds: this.opts.additionalWebviewBundleIds,
isSafari: this.isSafari(),
includeSafari: this.opts.includeSafariInWebviews,
useNewSafari: this.useNewSafari(),
pageLoadMs: this.pageLoadMs,
platformVersion: this.opts.platformVersion,
socketPath,
remoteDebugProxy: this.opts.remoteDebugProxy,
garbageCollectOnExecute: util.hasValue(this.opts.safariGarbageCollect)
? !!this.opts.safariGarbageCollect
: false,
udid: this.opts.udid,
logAllCommunication: this.opts.safariLogAllCommunication,
logAllCommunicationHexDump: this.opts.safariLogAllCommunicationHexDump,
socketChunkSize: this.opts.safariSocketChunkSize,
usbmuxdRemoteHost: this.opts.usbmuxdRemoteHost,
usbmuxdRemotePort: this.opts.usbmuxdRemotePort,
}, this.isRealDevice());
};
/**
* Set context
*
* @param {?string} name - The name of context to set. It could be 'null' as NATIVE_WIN.
* @param {callback} callback The callback. (It is not called in this method)
* @param {boolean} skipReadyCheck - Whether it waits for the new context is ready
*/
commands.setContext = async function setContext (name, callback, skipReadyCheck) {
function alreadyInContext (desired, current) {
return (desired === current ||
(desired === null && current === NATIVE_WIN) ||
(desired === NATIVE_WIN && current === null));
}
function isNativeContext (context) {
return context === NATIVE_WIN || context === null;
}
// allow the full context list to be passed in
if (name && name.id) {
name = name.id;
}
log.debug(`Attempting to set context to '${name || NATIVE_WIN}' from '${this.curContext ? this.curContext : NATIVE_WIN}'`);
if (alreadyInContext(name, this.curContext) || alreadyInContext(_.replace(name, WEBVIEW_BASE, ''), this.curContext)) {
// already in the named context, no need to do anything
log.debug(`Already in '${name || NATIVE_WIN}' context. Doing nothing.`);
return;
}
if (isNativeContext(name)) {
// switching into the native context
this.curContext = null;
return;
}
// switching into a webview context
// if contexts have not already been retrieved, get them
if (_.isUndefined(this.contexts)) {
await this.getContexts();
}
let contextId = _.replace(name, WEBVIEW_BASE, '');
if (contextId === '') {
// allow user to pass in "WEBVIEW" without an index
// the second context will be the first webview as
// the first is always NATIVE_APP
contextId = this.contexts[1];
}
if (!_.includes(this.contexts, contextId)) {
throw new errors.NoSuchContextError();
}
const oldContext = this.curContext;
this.curContext = this.curWindowHandle = contextId;
// `contextId` will be in the form of `appId.pageId` in this case
const [appIdKey, pageIdKey] = _.map(contextId.split('.'), (id) => parseInt(id, 10));
try {
this.selectingNewPage = true;
await this.remote.selectPage(appIdKey, pageIdKey, skipReadyCheck);
} catch (err) {
this.curContext = this.curWindowHandle = oldContext;
throw err;
} finally {
this.selectingNewPage = false;
}
// attempt to start performance logging, if requested
if (this.opts.enablePerformanceLogging && this.remote) {
log.debug(`Starting performance log on '${this.curContext}'`);
this.logs.performance = new IOSPerformanceLog(this.remote);
await this.logs.performance.startCapture();
}
// start safari logging if the logs handlers are active
if (name && name !== NATIVE_WIN && this.logs) {
if (this.logs.safariConsole) {
await this.remote.startConsole(this.logs.safariConsole.addLogLine.bind(this.logs.safariConsole));
}
if (this.logs.safariNetwork) {
await this.remote.startNetwork(this.logs.safariNetwork.addLogLine.bind(this.logs.safariNetwork));
}
}
};
extensions.connectToRemoteDebugger = async function connectToRemoteDebugger () {
this.remote = await this.getNewRemoteDebugger();
this.remote.on(RemoteDebugger.EVENT_PAGE_CHANGE, this.onPageChange.bind(this));
this.remote.on(RemoteDebugger.EVENT_FRAMES_DETACHED, () => {
if (!_.isEmpty(this.curWebFrames)) {
log.debug(`Clearing ${util.pluralize('frame', this.curWebFrames.length, true)}: ${this.curWebFrames.join(', ')}`);
}
this.curWebFrames = [];
});
await this.remote.connect(this.opts.webviewConnectTimeout);
};
extensions.listWebFrames = async function listWebFrames (useUrl = true) {
if (!this.opts.bundleId) {
log.errorAndThrow('Cannot enter web frame without a bundle ID');
}
useUrl = useUrl && !this.isRealDevice() && !!this.getCurrentUrl();
log.debug(`Selecting by url: ${useUrl} ${useUrl ? `(expected url: '${this.getCurrentUrl()}')` : ''}`);
const currentUrl = useUrl ? this.getCurrentUrl() : undefined;
let pageArray = [];
const getWebviewPages = async () => {
try {
return await this.remote.selectApp(currentUrl, this.opts.webviewConnectRetries, this.opts.ignoreAboutBlankUrl);
} catch (err) {
log.debug(`No available web pages: ${err.message}`);
return [];
}
};
if (this.remote && this.remote.appIdKey) {
// already connected
pageArray = await getWebviewPages();
} else {
if (!this.remote) {
await this.connectToRemoteDebugger();
}
await this.remote.setConnectionKey();
pageArray = await getWebviewPages();
const alertErrorMsg = 'Close alert failed. Retry.';
try {
await retryInterval(6, 1000, async () => {
if (!await this.closeAlertBeforeTest()) {
throw new Error(alertErrorMsg);
}
});
} catch (err) {
// if the loop to close alerts failed to dismiss, ignore,
// otherwise log and throw the error
if (err.message !== alertErrorMsg) {
log.errorAndThrow(err);
}
}
}
if (pageArray.length === 0) {
// we have no web frames, but continue anyway
log.debug('No web frames found.');
}
return pageArray;
};
commands.getContexts = async function getContexts () {
log.debug('Getting list of available contexts');
const contexts = await this.getContextsAndViews(false);
const mapFn = this.opts.fullContextList
? function (context) {
return {
id: context.id.toString(),
title: context.view.title,
url: context.view.url,
bundleId: context.view.bundleId,
};
}
: (context) => context.id.toString();
return contexts.map(mapFn);
};
/**
* @typedef {Object} Context
*
* @property {string} id - The identifier of the context. The native context
* will be 'NATIVE_APP' and the webviews will be
* 'WEBVIEW_xxx'
* @property {?string} title - The title associated with the webview content
* @property {?string} url - The url associated with the webview content
*/
/**
* Get the contexts available, with information about the url and title of each
* webview
*
* @param {Object} opts - Options set, which can include `waitForWebviewMs` to
* specify the period to poll for available webviews
* @returns {Array} List of Context objects
*/
extensions.mobileGetContexts = async function mobileGetContexts (opts = {}) {
let {
waitForWebviewMs = 0,
} = opts;
// make sure it is a number, so the duration check works properly
if (!_.isNumber(waitForWebviewMs)) {
waitForWebviewMs = parseInt(waitForWebviewMs, 10);
if (isNaN(waitForWebviewMs)) {
waitForWebviewMs = 0;
}
}
const curOpt = this.opts.fullContextList;
// `gst-atom-ios-driver#getContexts` returns the full list of contexts
// if this option is on
this.opts.fullContextList = true;
const timer = new timing.Timer().start();
try {
let contexts;
do {
contexts = await this.getContexts();
if (contexts.length >= 2) {
log.debug(`Found webview context after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
return contexts;
}
log.debug(`No webviews found in ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
} while (timer.getDuration().asMilliSeconds < waitForWebviewMs);
return contexts;
} finally {
// reset the option so there are no side effects
this.opts.fullContextList = curOpt;
}
};
commands.setWindow = async function setWindow (name, skipReadyCheck) {
try {
await this.setContext(name, _.noop, skipReadyCheck);
} catch (err) {
// translate the error in terms of windows
throw isErrorType(err, errors.NoSuchContextError)
? new errors.NoSuchWindowError()
: err;
}
};
commands.getWindowHandle = async function getWindowHandle () { // eslint-disable-line require-await
if (!this.isWebContext()) {
throw new errors.NotImplementedError();
}
log.debug(`Getting current window handle`);
return this.curContext;
};
commands.getWindowHandles = async function getWindowHandles () {
if (!this.isWebContext()) {
throw new errors.NotImplementedError();
}
log.debug('Getting list of available window handles');
const contexts = await this.getContextsAndViews(false);
return contexts
// get rid of the native app context
.filter((context) => context.id !== NATIVE_WIN)
// get the `app.id` format expected
.map((context) => context.view.id.toString());
};
extensions.onPageChange = async function onPageChange (pageChangeNotification) {
log.debug(`Remote debugger notified us of a new page listing: ${JSON.stringify(pageChangeNotification)}`);
if (this.selectingNewPage) {
log.debug('We are in the middle of selecting a page, ignoring');
return;
}
if (!this.remote || !this.remote.isConnected) {
log.debug('We have not yet connected, ignoring');
return;
}
const {appIdKey, pageArray} = pageChangeNotification;
let newIds = [];
let newPages = [];
let keyId = null;
for (const page of pageArray) {
const id = page.id.toString();
newIds.push(id);
if (page.isKey) {
keyId = id;
}
const contextId = `${appIdKey}.${id}`;
// add if this is a new page
if (!_.includes(this.contexts, contextId)) {
newPages.push(id);
this.contexts.push(contextId);
}
}
if (!keyId) {
// if there is no key id, pull the first id from the page array and use that
// as a stand in
log.debug('No key id found. Choosing first id from page array');
keyId = newIds[0] || null;
}
if (!util.hasValue(this.curContext)) {
log.debug('We do not appear to have window set yet, ignoring');
return;
}
const [curAppIdKey, curPageIdKey] = this.curContext.split('.');
if (curAppIdKey !== appIdKey) {
log.debug('Page change not referring to currently selected app, ignoring.');
return;
}
let newPage = null;
if (newPages.length) {
newPage = _.last(newPages);
log.debug(`We have new pages, selecting page '${newPage}'`);
} else if (!_.includes(newIds, curPageIdKey)) {
log.debug('New page listing from remote debugger does not contain ' +
'current window; assuming it is closed');
if (!util.hasValue(keyId)) {
log.error('Do not have our current window anymore, and there ' +
'are not any more to load! Doing nothing...');
this.setCurrentUrl(undefined);
return;
}
log.debug(`Debugger already selected page '${keyId}', ` +
`confirming that choice.`);
this.curContext = `${appIdKey}.${keyId}`;
newPage = keyId;
} else {
// at this point, there are no new pages, and the current page still exists
log.debug('Checking if page needs to load');
// If a window navigates to an anchor it doesn't always fire a page
// callback event. Let's check if we wound up in such a situation.
const needsPageLoad = (() => {
// need to map the page ids to context ids
const contextArray = _.map(pageArray, (page) => `${appIdKey}.${page.id}`);
// check if the current context exists in both our recorded contexts,
// and the page array
return !_.isEqual(_.find(this.contexts, this.curContext), _.find(contextArray, this.curContext));
})();
if (needsPageLoad) {
log.debug('Page load needed. Loading...');
await this.remote.pageLoad();
}
log.debug('New page listing is same as old, doing nothing');
}
// make sure that the page listing isn't indicating a redirect
if (util.hasValue(this.curContext)) {
let currentPageId = parseInt(_.last(this.curContext.split('.')), 10);
let page = _.find(pageArray, (p) => parseInt(p.id, 10) === currentPageId);
if (page && page.url !== this.getCurrentUrl()) {
log.debug(`Redirected from '${this.getCurrentUrl()}' to '${page.url}'`);
this.setCurrentUrl(page.url);
}
}
if (util.hasValue(newPage)) {
this.selectingNewPage = true;
const oldContext = this.curContext;
this.curContext = `${appIdKey}.${newPage}`;
// do not wait, as this can take a long time, and the response is not necessary
this.remote.selectPage(appIdKey, parseInt(newPage, 10))
.catch((err) => { // eslint-disable-line promise/prefer-await-to-callbacks
log.warn(`Failed to select page: ${err.message}`);
this.curContext = oldContext;
});
this.selectingNewPage = false;
}
this.windowHandleCache = pageArray;
};
Object.assign(extensions, commands, helpers);
export default extensions;