appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
635 lines • 28 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.notifyBiDiContextChange = notifyBiDiContextChange;
const appium_remote_debugger_1 = require("appium-remote-debugger");
const driver_1 = require("appium/driver");
const support_1 = require("appium/support");
const ios_performance_log_1 = require("../device-log/ios-performance-log");
const lodash_1 = __importDefault(require("lodash"));
const utils_1 = require("../utils");
const models_1 = require("./bidi/models");
const constants_1 = require("./bidi/constants");
const log_1 = require("./log");
const WEBVIEW_WIN = 'WEBVIEW';
const WEBVIEW_BASE = `${WEBVIEW_WIN}_`;
const DEFAULT_REMOTE_DEBUGGER_CONNECT_TIMEOUT_MS = 5000;
const DEFAULT_LIST_WEB_FRAMES_RETRIES = 20;
const DEFAULT_NATIVE_WINDOW_HANDLE = '1';
const extensions = {
/**
* @this {XCUITestDriver}
* @param {boolean} [useUrl=false]
* @returns {Promise<import('./types').ViewContext<typeof NATIVE_WIN>[]>}
*/
async getContextsAndViews(useUrl = true) {
this.log.debug('Retrieving contexts and views');
const webviews = await this.listWebFrames(useUrl);
/**
* @type {import('./types').ViewContext<typeof NATIVE_WIN>[]}
*/
const ctxs = [{ id: utils_1.NATIVE_WIN, view: {} }];
this.contexts = [utils_1.NATIVE_WIN];
for (const view of webviews) {
ctxs.push({ id: `${WEBVIEW_BASE}${view.id}`, view });
this.contexts.push(view.id.toString());
}
return ctxs;
},
/**
* Right now we don't necessarily wait for webview
* and frame to load, which leads to race conditions and flakiness,
* let's see if we can transition to something better
* @this {XCUITestDriver}
* @returns {boolean}
*/
useNewSafari() {
return this.isSimulator() && this.isSafari();
},
/**
* @this {XCUITestDriver}
* @returns {Promise<void>}
*/
async activateRecentWebview() {
this.log.debug('Activating a recent webview');
const timer = new support_1.timing.Timer().start();
const contextId = await this.getRecentWebviewContextId(/.*/, /.*/);
if (contextId) {
this.log.info(`Picking webview '${contextId}' after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
await this.setContext(contextId);
( /** @type {RemoteDebugger} */(this.remote)).cancelPageLoad();
return;
}
const appDict = ( /** @type {RemoteDebugger} */(this.remote)).appDict;
const errSuffix = `Make sure your web application is debuggable ` +
`and could be inspected in Safari Web Inspector.`;
if (lodash_1.default.isEmpty(appDict)) {
throw new Error(`The remote debugger did not return any connected web applications after ` +
`${timer.getDuration().asMilliSeconds.toFixed(0)}ms. ` +
`${errSuffix} ` +
`You may try to change the 'webviewConnectTimeout' capability value to ` +
`customize the retrieval timeout.`);
}
const errSuffix2 = `${errSuffix} You may try to change the 'webviewConnectRetries' ` +
`capability value to customize the amount of pages retrieval retries.`;
const appsWithPages = lodash_1.default.values(appDict).filter(({ pageArray }) => !lodash_1.default.isEmpty(pageArray));
if (appsWithPages.length > 0) {
throw new Error(`The remote debugger returned ${support_1.util.pluralize('web application', appsWithPages.length, true)} ` +
`with pages after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms, ` +
`although none of them matched our page search criteria. ${errSuffix2}`);
}
else {
throw new Error(`The remote debugger returned ${support_1.util.pluralize('web application', lodash_1.default.size(appDict), true)}, ` +
`but none of them had pages after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms. ` +
`${errSuffix2} Also, in rare cases the device restart or device OS upgrade may fix this ` +
`issue if none of the above advices helps.`);
}
},
/**
* @this {XCUITestDriver}
* @returns {Promise<import('../types').Page[]>}
*/
async listWebFrames(useUrl = true) {
const shouldFilterByUrl = useUrl && !this.isRealDevice() && !!this.getCurrentUrl();
this.log.debug(`Selecting by url: ${shouldFilterByUrl}` +
(shouldFilterByUrl ? ` (expected url: '${this.getCurrentUrl()}')` : ''));
if (!this.remote) {
await this.connectToRemoteDebugger();
}
const doListPages = async (/** @type {number} */ retries) => {
try {
const pageArray = await ( /** @type {RemoteDebugger} */(this.remote)).selectApp(shouldFilterByUrl ? this.getCurrentUrl() : undefined, retries, this.opts.ignoreAboutBlankUrl);
if (lodash_1.default.isEmpty(pageArray)) {
// we have no web frames, but continue anyway
this.log.debug(`No web frames found after ${support_1.util.pluralize('retry', retries, true)}`);
}
return pageArray;
}
catch (err) {
this.log.debug(`No available web pages after ${support_1.util.pluralize('retry', retries, true)}: ${err.message}`);
return [];
}
};
/** @type {number} */
const maxRetriesCount = lodash_1.default.isInteger(this.opts.webviewConnectRetries)
? Math.max(/** @type {number} */ (this.opts.webviewConnectRetries), 1)
: DEFAULT_LIST_WEB_FRAMES_RETRIES;
this.log.debug(`About to select a web application with ${support_1.util.pluralize('retry', maxRetriesCount, true)} ` +
`and 500ms interval between each retry. Consider customizing the value of 'webviewConnectRetries' ` +
`capability to change the amount of retries.`);
return await doListPages(maxRetriesCount);
},
/**
* @this {XCUITestDriver}
* @returns {Promise<void>}
*/
async connectToRemoteDebugger() {
this.remote = await this.getNewRemoteDebugger();
// @ts-ignore static is fine
this.remote.on(appium_remote_debugger_1.RemoteDebugger.EVENT_PAGE_CHANGE, this.onPageChange.bind(this));
// @ts-ignore static is fine
this.remote.on(appium_remote_debugger_1.RemoteDebugger.EVENT_FRAMES_DETACHED, () => {
if (!lodash_1.default.isEmpty(this.curWebFrames)) {
const curWebFrames = this.curWebFrames;
this.log.debug(`Clearing ${support_1.util.pluralize('frame', curWebFrames.length, true)}: ${curWebFrames.join(', ')}`);
}
this.curWebFrames = [];
});
const timeoutMs = this.opts.webviewConnectTimeout ?? DEFAULT_REMOTE_DEBUGGER_CONNECT_TIMEOUT_MS;
const apps = await this.remote.connect(timeoutMs);
if (lodash_1.default.isEmpty(apps)) {
this.log.info(`The remote debugger did not report any active web applications within ${timeoutMs}ms timeout. ` +
`Consider increasing the value of 'webviewConnectTimeout' capability to wait longer ` +
`on slower devices.`);
}
},
/**
* Retrieves the list of available contexts.
*
* The list includes extended context information, like URLs and page names.
* This is different from the standard `getContexts` API, because the latter
* only has web view names without any additional information.
*
* @remarks In situations where multiple web views are available at once, the
* client code would have to connect to each of them in order to detect the
* one which needs to be interacted with. This extra effort is not needed with
* the information provided by this extension.
* @param {number} [waitForWebviewMs=0] - The period to poll for available webview(s) (in ms)
* @returns {Promise<Context[]>} The list of available context objects along with their properties.
* @this {XCUITestDriver}
*/
async mobileGetContexts(waitForWebviewMs = 0) {
// make sure it is a number, so the duration check works properly
if (!lodash_1.default.isNumber(waitForWebviewMs)) {
waitForWebviewMs = parseInt(waitForWebviewMs, 10);
if (isNaN(waitForWebviewMs)) {
waitForWebviewMs = 0;
}
}
const curOpt = this.opts.fullContextList;
this.opts.fullContextList = true;
const timer = new support_1.timing.Timer().start();
try {
/** @type {FullContext[]} */
let contexts;
do {
contexts = /** @type {FullContext[]} */ (await this.getContexts());
if (contexts.length >= 2) {
this.log.debug(`Found webview context after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
return contexts;
}
this.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;
}
},
/**
* @this {XCUITestDriver}
* @param {import('./types').PageChangeNotification} pageChangeNotification
* @returns {Promise<void>}
*/
async onPageChange(pageChangeNotification) {
this.log.debug(`Remote debugger notified us of a new page listing: ${JSON.stringify(pageChangeNotification)}`);
if (this.selectingNewPage) {
this.log.debug('We are in the middle of selecting a page, ignoring');
return;
}
if (!this.remote?.isConnected) {
this.log.debug('We have not yet connected, ignoring');
return;
}
const { appIdKey, pageArray } = pageChangeNotification;
/** @type {string[]} */
const newIds = [];
/** @type {string[]} */
const newPages = [];
/** @type {string|null} */
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 (!lodash_1.default.includes(this.contexts, contextId)) {
if (isUrlIgnored(page.url, this.opts.safariIgnoreWebHostnames)) {
this.log.info(`Not tracking '${page.url}' page because it is blacklisted. ` +
`'safariIgnoreWebHostnames'=${this.opts.safariIgnoreWebHostnames}`);
}
else {
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
this.log.debug('No key id found. Choosing first id from page array');
keyId = newIds[0] || null;
}
if (!support_1.util.hasValue(this.curContext)) {
this.log.debug('We do not appear to have window set yet, ignoring');
return;
}
const [curAppIdKey, curPageIdKey] = this.curContext.split('.');
if (curAppIdKey !== appIdKey) {
this.log.debug('Page change not referring to currently selected app, ignoring.');
return;
}
/** @type {string|null} */
let newPage = null;
if (newPages.length) {
newPage = /** @type {string} */ (lodash_1.default.last(newPages));
this.log.debug(`We have new pages, selecting page '${newPage}'`);
}
else if (!lodash_1.default.includes(newIds, curPageIdKey)) {
this.log.debug('New page listing from remote debugger does not contain ' +
'current window; assuming it is closed');
if (!support_1.util.hasValue(keyId)) {
this.log.error('Do not have our current window anymore, and there ' +
'are not any more to load! Doing nothing...');
this.setCurrentUrl(undefined);
return;
}
this.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
this.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 = lodash_1.default.map(pageArray, (page) => `${appIdKey}.${page.id}`);
// check if the current context exists in both our recorded contexts,
// and the page array
return !lodash_1.default.isEqual(lodash_1.default.find(this.contexts, this.curContext), lodash_1.default.find(contextArray, this.curContext));
})();
if (needsPageLoad) {
this.log.debug('Page load needed. Loading...');
await this.remote.waitForDom();
}
this.log.debug('New page listing is same as old, doing nothing');
}
// make sure that the page listing isn't indicating a redirect
if (support_1.util.hasValue(this.curContext)) {
let currentPageId = parseInt(String(lodash_1.default.last(this.curContext.split('.'))), 10);
let page = lodash_1.default.find(pageArray, (p) => parseInt(String(p.id), 10) === currentPageId);
if (page && page.url !== this.getCurrentUrl()) {
this.log.debug(`Redirected from '${this.getCurrentUrl()}' to '${page.url}'`);
this.setCurrentUrl(page.url);
}
}
if (support_1.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
(async () => {
try {
await /** @type {RemoteDebugger} */ (this.remote).selectPage(appIdKey, parseInt(newPage, 10));
await notifyBiDiContextChange.bind(this)();
}
catch (e) {
this.log.warn(`Failed to select page: ${e.message}`);
this.curContext = oldContext;
}
finally {
this.selectingNewPage = false;
}
})();
}
this.windowHandleCache = pageArray;
},
};
const helpers = {
/**
* @this {XCUITestDriver}
* @returns {Promise<void>}
*/
async stopRemote(closeWindowBeforeDisconnecting = false) {
if (!this.remote) {
throw this.log.errorWithException('Tried to leave a web frame but were not in one');
}
if (closeWindowBeforeDisconnecting) {
await this.closeWindow();
}
await this.remote.disconnect();
this.curContext = null;
await notifyBiDiContextChange.bind(this)();
this.curWebFrames = [];
this.remote = null;
},
/**
* @this {XCUITestDriver}
* @param {string|undefined|null} url
*/
setCurrentUrl(url) {
this._currentUrl = url;
},
/**
* @this {XCUITestDriver}
* @returns {string|undefined|null}
*/
getCurrentUrl() {
return this._currentUrl;
},
/**
* @param {RegExp} titleRegExp
* @param {RegExp} urlRegExp
* @this {XCUITestDriver}
* @returns {Promise<string|undefined>}
*/
async getRecentWebviewContextId(titleRegExp, urlRegExp) {
if (!lodash_1.default.isRegExp(titleRegExp) && !lodash_1.default.isRegExp(urlRegExp)) {
throw new driver_1.errors.InvalidArgumentError('A regular expression for either web view title or url must be provided');
}
const currentUrl = this.getCurrentUrl();
const contexts = lodash_1.default.filter(await this.getContextsAndViews(false), 'view');
// first try to match by current url
if (currentUrl) {
const ctx = contexts.find(({ view }) => (view.url || '') === currentUrl);
if (ctx) {
return ctx.id;
}
}
// if not, try to match by regular expression
return contexts.find(({ view }) => (view.title && titleRegExp?.test(view.title)) || (view.url && urlRegExp?.test(view.url)))?.id;
},
/**
* @this {XCUITestDriver}
* @returns {boolean}
*/
isWebContext() {
return !!this.curContext && this.curContext !== utils_1.NATIVE_WIN;
},
/**
* @this {XCUITestDriver}
* @returns {boolean}
*/
isWebview() {
return this.isWebContext();
},
/**
* @this {XCUITestDriver}
* @returns {Promise<RemoteDebugger>}
*/
async getNewRemoteDebugger() {
const socketPath = this.isRealDevice()
? undefined
: (await /** @type {import('../driver').Simulator} */ (this.device).getWebInspectorSocket() ?? undefined);
return (0, appium_remote_debugger_1.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: support_1.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,
webInspectorMaxFrameLength: this.opts.safariWebInspectorMaxFrameLength,
pageLoadStrategy: this.caps.pageLoadStrategy,
}, this.isRealDevice());
},
};
const commands = {
/**
* @this {XCUITestDriver}
* @returns {Promise<string>}
*/
async getCurrentContext() {
if (this.curContext && this.curContext !== utils_1.NATIVE_WIN) {
return `${WEBVIEW_BASE}${this.curContext}`;
}
return utils_1.NATIVE_WIN;
},
/**
* Set context
*
* @param {string|Context} name - The name of context to set. It could be 'null' as NATIVE_WIN.
* @param {any} [callback] The callback. (It is not called in this method)
* @param {boolean} [skipReadyCheck=false] - Whether it waits for the new context is ready
* @this {XCUITestDriver}
* @returns {Promise<void>}
*/
async setContext(name, callback, skipReadyCheck = false) {
function alreadyInContext(desired, current) {
return (desired === current ||
(desired === null && current === utils_1.NATIVE_WIN) ||
(desired === utils_1.NATIVE_WIN && current === null));
}
function isNativeContext(context) {
return context === utils_1.NATIVE_WIN || context === null;
}
// allow the full context list to be passed in
const strName = String(typeof name === 'object' && name.id ? name.id : name);
this.log.debug(`Attempting to set context to '${strName || utils_1.NATIVE_WIN}' from '${this.curContext ? this.curContext : utils_1.NATIVE_WIN}'`);
if (alreadyInContext(strName, this.curContext) ||
alreadyInContext(lodash_1.default.replace(strName, WEBVIEW_BASE, ''), this.curContext)) {
// already in the named context, no need to do anything
this.log.debug(`Already in '${strName || utils_1.NATIVE_WIN}' context. Doing nothing.`);
return;
}
if (isNativeContext(strName)) {
// switching into the native context
this.curContext = null;
await notifyBiDiContextChange.bind(this)();
return;
}
// switching into a webview context
// if contexts have not already been retrieved, get them
if (lodash_1.default.isUndefined(this.contexts)) {
await this.getContexts();
}
let contextId = lodash_1.default.replace(strName, 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 = /** @type {string[]} */ (this.contexts)[1];
}
if (!lodash_1.default.includes(this.contexts, contextId)) {
throw new driver_1.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] = lodash_1.default.map(contextId.split('.'), (id) => parseInt(id, 10));
try {
this.selectingNewPage = true;
await ( /** @type {RemoteDebugger} */(this.remote)).selectPage(appIdKey, pageIdKey, skipReadyCheck);
await notifyBiDiContextChange.bind(this)();
}
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) {
const context = this.curContext;
this.log.debug(`Starting performance log on '${context}'`);
[this.logs.performance,] = log_1.assignBiDiLogListener.bind(this)(new ios_performance_log_1.IOSPerformanceLog({
remoteDebugger: this.remote,
log: this.log,
}), {
type: 'performance',
context,
});
await this.logs.performance?.startCapture();
}
// start safari logging if the logs handlers are active
if (name && name !== utils_1.NATIVE_WIN && this.logs) {
if (this.logs.safariConsole) {
( /** @type {RemoteDebugger} */(this.remote)).startConsole(this.logs.safariConsole.onConsoleLogEvent.bind(this.logs.safariConsole));
}
if (this.logs.safariNetwork) {
( /** @type {RemoteDebugger} */(this.remote)).startNetwork(this.logs.safariNetwork.onNetworkEvent.bind(this.logs.safariNetwork));
}
}
},
/**
* @this {XCUITestDriver}
* @returns {Promise<string[]|FullContext[]>}
*/
async getContexts() {
this.log.debug('Getting list of available contexts');
const contexts = await this.getContextsAndViews(false);
if (this.opts.fullContextList) {
return /** @type {import('./types').FullContext[]} */ (contexts.map((context) => ({
id: context.id.toString(),
title: context.view.title,
url: context.view.url,
bundleId: context.view.bundleId,
})));
}
return /** @type {string[]} */ (contexts.map((context) => context.id.toString()));
},
/**
* @this {XCUITestDriver}
* @param {string} name
* @param {boolean} [skipReadyCheck]
* @returns {Promise<void>}
*/
async setWindow(name, skipReadyCheck) {
if (!this.isWebContext()) {
// https://github.com/appium/appium/issues/20710
return;
}
try {
await this.setContext(name, lodash_1.default.noop, skipReadyCheck);
}
catch (err) {
// translate the error in terms of windows
throw (0, driver_1.isErrorType)(err, driver_1.errors.NoSuchContextError) ? new driver_1.errors.NoSuchWindowError() : err;
}
},
/**
* @this {XCUITestDriver}
* @returns {Promise<string>}
*/
async getWindowHandle() {
if (!this.isWebContext()) {
// https://github.com/appium/appium/issues/20710
return DEFAULT_NATIVE_WINDOW_HANDLE;
}
if (!this.curContext) {
throw new driver_1.errors.InvalidContextError();
}
this.log.debug(`Getting current window handle`);
return this.curContext;
},
/**
* @this {XCUITestDriver}
* @returns {Promise<string[]>}
*/
async getWindowHandles() {
if (!this.isWebContext()) {
// https://github.com/appium/appium/issues/20710
return [DEFAULT_NATIVE_WINDOW_HANDLE];
}
this.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 !== utils_1.NATIVE_WIN)
// get the `app.id` format expected
.map((context) =>
/**
* This is non-nullable because the `FullContext` having `id` `NATIVE_WIN` _looks like_ the only with an empty view.
* @type {NonNullable<FullContext['view']['id']>}
*/ (context.view.id).toString()));
},
};
/**
* Checks if a URL is blacklisted in the 'safariIgnoreWebHostnames' capability
*
* @param {string} url
* @param {string} [safariIgnoreWebHostnames]
* @returns {boolean}
*/
function isUrlIgnored(url, safariIgnoreWebHostnames) {
if (!safariIgnoreWebHostnames || lodash_1.default.isEmpty(safariIgnoreWebHostnames)) {
return false;
}
const ignoredHosts = safariIgnoreWebHostnames
.split(',')
.map((b) => b.trim())
.filter((b) => !lodash_1.default.isEmpty(b));
for (const ignoredHost of ignoredHosts) {
if (ignoredHost === 'about:blank' && url === 'about:blank') {
return true;
}
else {
try {
const hostname = new URL(url).hostname;
if (hostname === ignoredHost) {
return true;
}
}
catch {
// do nothing if invalid URL
}
}
}
return false;
}
/**
* https://github.com/appium/appium/issues/20741
*
* @this {XCUITestDriver}
* @returns {Promise<void>}
*/
async function notifyBiDiContextChange() {
const name = await this.getCurrentContext();
this.eventEmitter.emit(constants_1.BIDI_EVENT_NAME, (0, models_1.makeContextUpdatedEvent)(name));
this.eventEmitter.emit(constants_1.BIDI_EVENT_NAME, (0, models_1.makeObsoleteContextUpdatedEvent)(name));
}
exports.default = { ...helpers, ...extensions, ...commands };
/**
* @typedef {import('../driver').XCUITestDriver} XCUITestDriver
* @typedef {import('./types').Context} Context
* @typedef {import('./types').FullContext} FullContext
*/
//# sourceMappingURL=context.js.map