UNPKG

codeceptjs

Version:

Supercharged End 2 End Testing Framework for NodeJS

1,790 lines (1,653 loc) 48.1 kB
let webdriverio const fs = require('fs') const axios = require('axios').default const { v4: uuidv4 } = require('uuid') const Webdriver = require('./WebDriver') const AssertionFailedError = require('../assert/error') const { truth } = require('../assert/truth') const recorder = require('../recorder') const Locator = require('../locator') const ConnectionRefused = require('./errors/ConnectionRefused') const mobileRoot = '//*' const webRoot = 'body' const supportedPlatform = { android: 'Android', iOS: 'iOS', } const vendorPrefix = { appium: 'appium', } /** * Appium helper extends [Webdriver](http://codecept.io/helpers/WebDriver/) helper. * It supports all browser methods and also includes special methods for mobile apps testing. * You can use this helper to test Web on desktop and mobile devices and mobile apps. * * ## Appium Installation * * Appium is an open source test automation framework for use with native, hybrid and mobile web apps that implements the WebDriver protocol. * It allows you to run Selenium tests on mobile devices and also test native, hybrid and mobile web apps. * * Download and install [Appium](https://appium.io/docs/en/2.1/) * * ```sh * npm install -g appium * ``` * * Launch the daemon: `appium` * * ## Helper configuration * * This helper should be configured in codecept.conf.ts or codecept.conf.js * * * `appiumV2`: by default is true, set this to false if you want to run tests with AppiumV1. See more how to setup [here](https://codecept.io/mobile/#setting-up) * * `app`: Application path. Local path or remote URL to an .ipa or .apk file, or a .zip containing one of these. Alias to desiredCapabilities.appPackage * * `host`: (default: 'localhost') Appium host * * `port`: (default: '4723') Appium port * * `platform`: (Android or IOS), which mobile OS to use; alias to desiredCapabilities.platformName * * `restart`: restart browser or app between tests (default: true), if set to false cookies will be cleaned but browser window will be kept and for apps nothing will be changed. * * `desiredCapabilities`: [], Appium capabilities, see below * * `platformName` - Which mobile OS platform to use * * `appPackage` - Java package of the Android app you want to run * * `appActivity` - Activity name for the Android activity you want to launch from your package. * * `deviceName`: The kind of mobile device or emulator to use * * `platformVersion`: Mobile OS version * * `app` - The absolute local path or remote http URL to an .ipa or .apk file, or a .zip containing one of these. Appium will attempt to install this app binary on the appropriate device first. * * `browserName`: Name of mobile web browser to automate. Should be an empty string if automating an app instead. * * Example Android App: * * ```js * { * helpers: { * Appium: { * platform: "Android", * desiredCapabilities: { * appPackage: "com.example.android.myApp", * appActivity: "MainActivity", * deviceName: "OnePlus3", * platformVersion: "6.0.1" * } * } * } * } * ``` * * Example iOS Mobile Web with local Appium: * * ```js * { * helpers: { * Appium: { * platform: "iOS", * url: "https://the-internet.herokuapp.com/", * desiredCapabilities: { * deviceName: "iPhone X", * platformVersion: "12.0", * browserName: "safari" * } * } * } * } * ``` * * Example iOS Mobile Web on BrowserStack: * * ```js * { * helpers: { * Appium: { * host: "hub-cloud.browserstack.com", * port: 4444, * user: process.env.BROWSERSTACK_USER, * key: process.env.BROWSERSTACK_KEY, * platform: "iOS", * url: "https://the-internet.herokuapp.com/", * desiredCapabilities: { * realMobile: "true", * device: "iPhone 8", * os_version: "12", * browserName: "safari" * } * } * } * } * ``` * * Example Android App using AppiumV2 on BrowserStack: * * ```js * { * helpers: { * Appium: { * appiumV2: true, // By default is true, set to false if you want to run against Appium v1 * host: "hub-cloud.browserstack.com", * port: 4444, * user: process.env.BROWSERSTACK_USER, * key: process.env.BROWSERSTACK_KEY, * app: `bs://c700ce60cf1gjhgjh3ae8ed9770ghjg5a55b8e022f13c5827cg`, * browser: '', * desiredCapabilities: { * 'appPackage': data.packageName, * 'deviceName': process.env.DEVICE || 'Google Pixel 3', * 'platformName': process.env.PLATFORM || 'android', * 'platformVersion': process.env.OS_VERSION || '10.0', * 'automationName': process.env.ENGINE || 'UIAutomator2', * 'newCommandTimeout': 300000, * 'androidDeviceReadyTimeout': 300000, * 'androidInstallTimeout': 90000, * 'appWaitDuration': 300000, * 'autoGrantPermissions': true, * 'gpsEnabled': true, * 'isHeadless': false, * 'noReset': false, * 'noSign': true, * 'bstack:options' : { * "appiumVersion" : "2.0.1", * }, * } * } * } * } * ``` * * Additional configuration params can be used from <https://github.com/appium/appium/blob/master/packages/appium/docs/en/guides/caps.md> * * ## Access From Helpers * * Receive Appium client from a custom helper by accessing `browser` property: * * ```js * let browser = this.helpers['Appium'].browser * ``` * * ## Methods */ class Appium extends Webdriver { /** * Appium Special Methods for Mobile only * @augments WebDriver */ // @ts-ignore constructor(config) { super(config) this.isRunning = false this.appiumV2 = config.appiumV2 || true this.axios = axios.create() webdriverio = require('webdriverio') if (!config.appiumV2) { console.log('The Appium core team does not maintain Appium 1.x anymore since the 1st of January 2022. Appium 2.x is used by default.') console.log('More info: https://bit.ly/appium-v2-migration') console.log('This Appium 1.x support will be removed in next major release.') } } _validateConfig(config) { if (!(config.app || config.platform) && !config.browser) { throw new Error(` Appium requires either platform and app or a browser to be set. Check your codeceptjs config file to ensure these are set properly { "helpers": { "Appium": { "app": "/path/to/app/package" "platform": "MOBILE_OS", } } } `) } // set defaults const defaults = { // webdriverio defaults protocol: 'http', hostname: '0.0.0.0', // webdriverio specs port: 4723, path: '/wd/hub', // config waitForTimeout: 1000, // ms logLevel: 'error', capabilities: {}, deprecationWarnings: false, restart: true, manualStart: false, timeouts: { script: 0, // ms }, } // override defaults with config config = Object.assign(defaults, config) config.baseUrl = config.url || config.baseUrl if (config.desiredCapabilities && Object.keys(config.desiredCapabilities).length) { config.capabilities = this.appiumV2 === true ? this._convertAppiumV2Caps(config.desiredCapabilities) : config.desiredCapabilities } if (this.appiumV2) { config.capabilities[`${vendorPrefix.appium}:deviceName`] = config[`${vendorPrefix.appium}:device`] || config.capabilities[`${vendorPrefix.appium}:deviceName`] config.capabilities[`${vendorPrefix.appium}:browserName`] = config[`${vendorPrefix.appium}:browser`] || config.capabilities[`${vendorPrefix.appium}:browserName`] config.capabilities[`${vendorPrefix.appium}:app`] = config[`${vendorPrefix.appium}:app`] || config.capabilities[`${vendorPrefix.appium}:app`] config.capabilities[`${vendorPrefix.appium}:tunnelIdentifier`] = config[`${vendorPrefix.appium}:tunnelIdentifier`] || config.capabilities[`${vendorPrefix.appium}:tunnelIdentifier`] // Adding the code to connect to sauce labs via sauce tunnel } else { config.capabilities.deviceName = config.device || config.capabilities.deviceName config.capabilities.browserName = config.browser || config.capabilities.browserName config.capabilities.app = config.app || config.capabilities.app config.capabilities.tunnelIdentifier = config.tunnelIdentifier || config.capabilities.tunnelIdentifier // Adding the code to connect to sauce labs via sauce tunnel } config.capabilities.platformName = config.platform || config.capabilities.platformName config.waitForTimeoutInSeconds = config.waitForTimeout / 1000 // convert to seconds // [CodeceptJS compatible] transform host to hostname config.hostname = config.host || config.hostname if (!config.app && config.capabilities.browserName) { this.isWeb = true this.root = webRoot } else { this.isWeb = false this.root = mobileRoot } this.platform = null if (config.capabilities[`${vendorPrefix.appium}:platformName`]) { this.platform = config.capabilities[`${vendorPrefix.appium}:platformName`].toLowerCase() } if (config.capabilities.platformName) { this.platform = config.capabilities.platformName.toLowerCase() } return config } _convertAppiumV2Caps(capabilities) { const _convertedCaps = {} for (const [key, value] of Object.entries(capabilities)) { if (!key.startsWith(vendorPrefix.appium)) { if (key !== 'platformName' && key !== 'bstack:options') { _convertedCaps[`${vendorPrefix.appium}:${key}`] = value } else { _convertedCaps[`${key}`] = value } } else { _convertedCaps[`${key}`] = value } } return _convertedCaps } static _config() { return [ { name: 'app', message: 'Application package. Path to file or url', default: 'http://localhost', }, { name: 'platform', message: 'Mobile Platform', type: 'list', choices: ['iOS', supportedPlatform.android], default: supportedPlatform.android, }, { name: 'device', message: 'Device to run tests on', default: 'emulator', }, ] } async _startBrowser() { if (this.appiumV2 === true) { this.options.capabilities = this._convertAppiumV2Caps(this.options.capabilities) this.options.desiredCapabilities = this._convertAppiumV2Caps(this.options.desiredCapabilities) } try { if (this.options.multiremote) { this.browser = await webdriverio.multiremote(this.options.multiremote) } else { this.browser = await webdriverio.remote(this.options) } } catch (err) { if (err.toString().indexOf('ECONNREFUSED')) { throw new ConnectionRefused(err) } throw err } this.$$ = this.browser.$$.bind(this.browser) this.isRunning = true if (this.options.timeouts && this.isWeb) { await this.defineTimeout(this.options.timeouts) } if (this.options.windowSize === 'maximize' && !this.platform) { const res = await this.browser.execute('return [screen.width, screen.height]') return this.browser.windowHandleSize({ width: res.value[0], height: res.value[1], }) } if (this.options.windowSize && this.options.windowSize.indexOf('x') > 0 && !this.platform) { const dimensions = this.options.windowSize.split('x') await this.browser.windowHandleSize({ width: dimensions[0], height: dimensions[1], }) } } async _after() { if (!this.isRunning) return if (this.options.restart) { this.isRunning = false return this.browser.deleteSession() } if (this.isWeb && !this.platform) { return super._after() } } async _withinBegin(context) { if (this.isWeb) { return super._withinBegin(context) } if (context === 'webview') { return this.switchToWeb() } if (typeof context === 'object') { if (context.web) return this.switchToWeb(context.web) if (context.webview) return this.switchToWeb(context.webview) } return this.switchToContext(context) } _withinEnd() { if (this.isWeb) { return super._withinEnd() } return this.switchToNative() } _buildAppiumEndpoint() { const { protocol, port, hostname, path } = this.browser.options // Ensure path does NOT end with a slash to prevent double slashes const normalizedPath = path.replace(/\/$/, '') // Build path to Appium REST API endpoint return `${protocol}://${hostname}:${port}${normalizedPath}/session/${this.browser.sessionId}` } /** * Execute code only on iOS * * ```js * I.runOnIOS(() => { * I.click('//UIAApplication[1]/UIAWindow[1]/UIAButton[1]'); * I.see('Hi, IOS', '~welcome'); * }); * ``` * * Additional filter can be applied by checking for capabilities. * For instance, this code will be executed only on iPhone 5s: * * * ```js * I.runOnIOS({deviceName: 'iPhone 5s'},() => { * // ... * }); * ``` * * Also capabilities can be checked by a function. * * ```js * I.runOnAndroid((caps) => { * // caps is current config of desiredCapabiliites * return caps.platformVersion >= 6 * },() => { * // ... * }); * ``` * * @param {*} caps * @param {*} fn */ async runOnIOS(caps, fn) { if (this.platform !== 'ios') return recorder.session.start('iOS-only actions') this._runWithCaps(caps, fn) recorder.add('restore from iOS session', () => recorder.session.restore()) return recorder.promise() } /** * Execute code only on Android * * ```js * I.runOnAndroid(() => { * I.click('io.selendroid.testapp:id/buttonTest'); * }); * ``` * * Additional filter can be applied by checking for capabilities. * For instance, this code will be executed only on Android 6.0: * * * ```js * I.runOnAndroid({platformVersion: '6.0'},() => { * // ... * }); * ``` * * Also capabilities can be checked by a function. * In this case, code will be executed only on Android >= 6. * * ```js * I.runOnAndroid((caps) => { * // caps is current config of desiredCapabiliites * return caps.platformVersion >= 6 * },() => { * // ... * }); * ``` * * @param {*} caps * @param {*} fn */ async runOnAndroid(caps, fn) { if (this.platform !== 'android') return recorder.session.start('Android-only actions') this._runWithCaps(caps, fn) recorder.add('restore from Android session', () => recorder.session.restore()) return recorder.promise() } /** * Execute code only in Web mode. * * ```js * I.runInWeb(() => { * I.waitForElement('#data'); * I.seeInCurrentUrl('/data'); * }); * ``` * */ async runInWeb() { if (!this.isWeb) return recorder.session.start('Web-only actions') recorder.add('restore from Web session', () => recorder.session.restore(), true) return recorder.promise() } _runWithCaps(caps, fn) { if (typeof caps === 'object') { for (const key in caps) { // skip if capabilities do not match if (this.config.desiredCapabilities[key] !== caps[key]) { return } } } if (typeof caps === 'function') { if (!fn) { fn = caps } else { // skip if capabilities are checked inside a function const enabled = caps(this.config.desiredCapabilities) if (!enabled) return } } fn() } /** * Returns app installation status. * * ```js * I.checkIfAppIsInstalled("com.example.android.apis"); * ``` * * @param {string} bundleId String ID of bundled app * @return {Promise<boolean>} * * Appium: support only Android */ async checkIfAppIsInstalled(bundleId) { onlyForApps.call(this, supportedPlatform.android) return this.browser.isAppInstalled(bundleId) } /** * Check if an app is installed. * * ```js * I.seeAppIsInstalled("com.example.android.apis"); * ``` * * @param {string} bundleId String ID of bundled app * @return {Promise<void>} * * Appium: support only Android */ async seeAppIsInstalled(bundleId) { onlyForApps.call(this, supportedPlatform.android) const res = await this.browser.isAppInstalled(bundleId) return truth(`app ${bundleId}`, 'to be installed').assert(res) } /** * Check if an app is not installed. * * ```js * I.seeAppIsNotInstalled("com.example.android.apis"); * ``` * * @param {string} bundleId String ID of bundled app * @return {Promise<void>} * * Appium: support only Android */ async seeAppIsNotInstalled(bundleId) { onlyForApps.call(this, supportedPlatform.android) const res = await this.browser.isAppInstalled(bundleId) return truth(`app ${bundleId}`, 'not to be installed').negate(res) } /** * Install an app on device. * * ```js * I.installApp('/path/to/file.apk'); * ``` * @param {string} path path to apk file * @return {Promise<void>} * * Appium: support only Android */ async installApp(path) { onlyForApps.call(this, supportedPlatform.android) return this.browser.installApp(path) } /** * Remove an app from the device. * * ```js * I.removeApp('appName', 'com.example.android.apis'); * ``` * * Appium: support only Android * * @param {string} appId * @param {string} [bundleId] ID of bundle */ async removeApp(appId, bundleId) { onlyForApps.call(this, supportedPlatform.android) return this.axios({ method: 'post', url: `${this._buildAppiumEndpoint()}/appium/device/remove_app`, data: { appId, bundleId }, }) } /** * Reset the currently running app for current session. * * ```js * I.resetApp(); * ``` * */ async resetApp() { onlyForApps.call(this) return this.axios({ method: 'post', url: `${this._buildAppiumEndpoint()}/appium/app/reset`, }) } /** * Check current activity on an Android device. * * ```js * I.seeCurrentActivityIs(".HomeScreenActivity") * ``` * @param {string} currentActivity * @return {Promise<void>} * * Appium: support only Android */ async seeCurrentActivityIs(currentActivity) { onlyForApps.call(this, supportedPlatform.android) const res = await this.browser.getCurrentActivity() return truth('current activity', `to be ${currentActivity}`).assert(res === currentActivity) } /** * Check whether the device is locked. * * ```js * I.seeDeviceIsLocked(); * ``` * * @return {Promise<void>} * * Appium: support only Android */ async seeDeviceIsLocked() { onlyForApps.call(this, supportedPlatform.android) const res = await this.browser.isLocked() return truth('device', 'to be locked').assert(res) } /** * Check whether the device is not locked. * * ```js * I.seeDeviceIsUnlocked(); * ``` * * @return {Promise<void>} * * Appium: support only Android */ async seeDeviceIsUnlocked() { onlyForApps.call(this, supportedPlatform.android) const res = await this.browser.isLocked() return truth('device', 'to be locked').negate(res) } /** * Check the device orientation * * ```js * I.seeOrientationIs('PORTRAIT'); * I.seeOrientationIs('LANDSCAPE') * ``` * * @return {Promise<void>} * * @param {'LANDSCAPE'|'PORTRAIT'} orientation LANDSCAPE or PORTRAIT * * Appium: support Android and iOS */ async seeOrientationIs(orientation) { onlyForApps.call(this) const res = await this.axios({ method: 'get', url: `${this._buildAppiumEndpoint()}/orientation`, }) const currentOrientation = res.data.value return truth('orientation', `to be ${orientation}`).assert(currentOrientation === orientation) } /** * Set a device orientation. Will fail, if app will not set orientation * * ```js * I.setOrientation('PORTRAIT'); * I.setOrientation('LANDSCAPE') * ``` * * @param {'LANDSCAPE'|'PORTRAIT'} orientation LANDSCAPE or PORTRAIT * * Appium: support Android and iOS */ async setOrientation(orientation) { onlyForApps.call(this) return this.axios({ method: 'post', url: `${this._buildAppiumEndpoint()}/orientation`, data: { orientation }, }) } /** * Get list of all available contexts * * ``` * let contexts = await I.grabAllContexts(); * ``` * * @return {Promise<string[]>} * * Appium: support Android and iOS */ async grabAllContexts() { onlyForApps.call(this) return this.browser.getContexts() } /** * Retrieve current context * * ```js * let context = await I.grabContext(); * ``` * * @return {Promise<string|null>} * * Appium: support Android and iOS */ async grabContext() { onlyForApps.call(this) return this.browser.getContext() } /** * Get current device activity. * * ```js * let activity = await I.grabCurrentActivity(); * ``` * * @return {Promise<string>} * * Appium: support only Android */ async grabCurrentActivity() { onlyForApps.call(this, supportedPlatform.android) return this.browser.getCurrentActivity() } /** * Get information about the current network connection (Data/WIFI/Airplane). * The actual server value will be a number. However WebdriverIO additional * properties to the response object to allow easier assertions. * * ```js * let con = await I.grabNetworkConnection(); * ``` * * @return {Promise<{}>} * * Appium: support only Android */ async grabNetworkConnection() { onlyForApps.call(this, supportedPlatform.android) const res = await this.browser.getNetworkConnection() return { value: res, inAirplaneMode: res.inAirplaneMode, hasWifi: res.hasWifi, hasData: res.hasData, } } /** * Get current orientation. * * ```js * let orientation = await I.grabOrientation(); * ``` * * @return {Promise<string>} * * Appium: support Android and iOS */ async grabOrientation() { onlyForApps.call(this) const res = await this.browser.orientation() this.debugSection('Orientation', res) return res } /** * Get all the currently specified settings. * * ```js * let settings = await I.grabSettings(); * ``` * * @return {Promise<string>} * * Appium: support Android and iOS */ async grabSettings() { onlyForApps.call(this) const res = await this.browser.getSettings() this.debugSection('Settings', JSON.stringify(res)) return res } /** * Switch to the specified context. * * @param {*} context the context to switch to */ async switchToContext(context) { return this.browser.switchContext(context) } /** * Switches to web context. * If no context is provided switches to the first detected web context * * ```js * // switch to first web context * I.switchToWeb(); * * // or set the context explicitly * I.switchToWeb('WEBVIEW_io.selendroid.testapp'); * ``` * * @return {Promise<void>} * * @param {string} [context] */ async switchToWeb(context) { this.isWeb = true this.defaultContext = 'body' if (context) return this.switchToContext(context) const contexts = await this.grabAllContexts() this.debugSection('Contexts', contexts.toString()) for (const idx in contexts) { if (contexts[idx].match(/^WEBVIEW/)) return this.switchToContext(contexts[idx]) } throw new Error('No WEBVIEW could be guessed, please specify one in params') } /** * Switches to native context. * By default switches to NATIVE_APP context unless other specified. * * ```js * I.switchToNative(); * * // or set context explicitly * I.switchToNative('SOME_OTHER_CONTEXT'); * ``` * @param {*} [context] * @return {Promise<void>} */ async switchToNative(context = null) { this.isWeb = false this.defaultContext = '//*' if (context) return this.switchToContext(context) return this.switchToContext('NATIVE_APP') } /** * Start an arbitrary Android activity during a session. * * ```js * I.startActivity('io.selendroid.testapp', '.RegisterUserActivity'); * ``` * * Appium: support only Android * * @param {string} appPackage * @param {string} appActivity * @return {Promise<void>} */ async startActivity(appPackage, appActivity) { onlyForApps.call(this, supportedPlatform.android) return this.browser.startActivity(appPackage, appActivity) } /** * Set network connection mode. * * * airplane mode * * wifi mode * * data data * * ```js * I.setNetworkConnection(0) // airplane mode off, wifi off, data off * I.setNetworkConnection(1) // airplane mode on, wifi off, data off * I.setNetworkConnection(2) // airplane mode off, wifi on, data off * I.setNetworkConnection(4) // airplane mode off, wifi off, data on * I.setNetworkConnection(6) // airplane mode off, wifi on, data on * ``` * See corresponding [webdriverio reference](https://webdriver.io/docs/api/chromium/#setnetworkconnection). * * Appium: support only Android * * @param {number} value The network connection mode bitmask * @return {Promise<number>} */ async setNetworkConnection(value) { onlyForApps.call(this, supportedPlatform.android) return this.browser.setNetworkConnection(value) } /** * Update the current setting on the device * * ```js * I.setSettings({cyberdelia: 'open'}); * ``` * * @param {object} settings object * * Appium: support Android and iOS */ async setSettings(settings) { onlyForApps.call(this) return this.browser.settings(settings) } /** * Hide the keyboard. * * ```js * // taps outside to hide keyboard per default * I.hideDeviceKeyboard(); * ``` * * Appium: support Android and iOS * */ async hideDeviceKeyboard() { onlyForApps.call(this) return this.axios({ method: 'post', url: `${this._buildAppiumEndpoint()}/appium/device/hide_keyboard`, data: {}, }) } /** * Send a key event to the device. * List of keys: https://developer.android.com/reference/android/view/KeyEvent.html * * ```js * I.sendDeviceKeyEvent(3); * ``` * * @param {number} keyValue Device specific key value * @return {Promise<void>} * * Appium: support only Android */ async sendDeviceKeyEvent(keyValue) { onlyForApps.call(this, supportedPlatform.android) return this.browser.pressKeyCode(keyValue) } /** * Open the notifications panel on the device. * * ```js * I.openNotifications(); * ``` * * @return {Promise<void>} * * Appium: support only Android */ async openNotifications() { onlyForApps.call(this, supportedPlatform.android) return this.browser.openNotifications() } /** * The Touch Action API provides the basis of all gestures that can be * automated in Appium. At its core is the ability to chain together ad hoc * individual actions, which will then be applied to an element in the * application on the device. * [See complete documentation](http://webdriver.io/api/mobile/touchAction.html) * * ```js * I.makeTouchAction("~buttonStartWebviewCD", 'tap'); * ``` * * @return {Promise<void>} * * Appium: support Android and iOS */ async makeTouchAction(locator, action) { onlyForApps.call(this) const element = await this.browser.$(parseLocator.call(this, locator)) return this.browser.touchAction({ action, element, }) } /** * Taps on element. * * ```js * I.tap("~buttonStartWebviewCD"); * ``` * * Shortcut for `makeTouchAction` * * @return {Promise<void>} * * @param {*} locator */ async tap(locator) { const { elementId } = await this.browser.$(parseLocator.call(this, locator)) return this.axios({ method: 'post', url: `${this._buildAppiumEndpoint()}/element/${elementId}/click`, data: {}, }) } /** * Perform a swipe on the screen or an element. * * ```js * let locator = "#io.selendroid.testapp:id/LinearLayout1"; * I.swipe(locator, 800, 1200, 1000); * ``` * * [See complete reference](http://webdriver.io/api/mobile/swipe.html) * * @param {CodeceptJS.LocatorOrString} locator * @param {number} xoffset * @param {number} yoffset * @param {number} [speed=1000] (optional), 1000 by default * @return {Promise<void>} * * Appium: support Android and iOS */ async swipe(locator, xoffset, yoffset, speed = 1000) { onlyForApps.call(this) const res = await this.browser.$(parseLocator.call(this, locator)) // if (!res.length) throw new ElementNotFound(locator, 'was not found in UI'); return this.performSwipe(await res.getLocation(), { x: (await res.getLocation()).x + xoffset, y: (await res.getLocation()).y + yoffset, }) } /** * Perform a swipe on the screen. * * ```js * I.performSwipe({ x: 300, y: 100 }, { x: 200, y: 100 }); * ``` * * @param {object} from * @param {object} to * * Appium: support Android and iOS */ async performSwipe(from, to) { await this.browser.performActions([ { id: uuidv4(), type: 'pointer', parameters: { pointerType: 'touch', }, actions: [ { duration: 0, x: from.x, y: from.y, type: 'pointerMove', origin: 'viewport', }, { button: 1, type: 'pointerDown', }, { duration: 200, type: 'pause', }, { duration: 600, x: to.x, y: to.y, type: 'pointerMove', origin: 'viewport', }, { button: 1, type: 'pointerUp', }, ], }, ]) await this.browser.pause(1000) } /** * Perform a swipe down on an element. * * ```js * let locator = "#io.selendroid.testapp:id/LinearLayout1"; * I.swipeDown(locator); // simple swipe * I.swipeDown(locator, 500); // set speed * I.swipeDown(locator, 1200, 1000); // set offset and speed * ``` * * @param {CodeceptJS.LocatorOrString} locator * @param {number} [yoffset] (optional) * @param {number} [speed=1000] (optional), 1000 by default * @return {Promise<void>} * * Appium: support Android and iOS */ async swipeDown(locator, yoffset = 1000, speed) { onlyForApps.call(this) if (!speed) { speed = yoffset yoffset = 100 } return this.swipe(parseLocator.call(this, locator), 0, yoffset, speed) } /** * * Perform a swipe left on an element. * * ```js * let locator = "#io.selendroid.testapp:id/LinearLayout1"; * I.swipeLeft(locator); // simple swipe * I.swipeLeft(locator, 500); // set speed * I.swipeLeft(locator, 1200, 1000); // set offset and speed * ``` * * @param {CodeceptJS.LocatorOrString} locator * @param {number} [xoffset] (optional) * @param {number} [speed=1000] (optional), 1000 by default * @return {Promise<void>} * * Appium: support Android and iOS */ async swipeLeft(locator, xoffset = 1000, speed) { onlyForApps.call(this) if (!speed) { speed = xoffset xoffset = 100 } return this.swipe(parseLocator.call(this, locator), -xoffset, 0, speed) } /** * Perform a swipe right on an element. * * ```js * let locator = "#io.selendroid.testapp:id/LinearLayout1"; * I.swipeRight(locator); // simple swipe * I.swipeRight(locator, 500); // set speed * I.swipeRight(locator, 1200, 1000); // set offset and speed * ``` * * @param {CodeceptJS.LocatorOrString} locator * @param {number} [xoffset] (optional) * @param {number} [speed=1000] (optional), 1000 by default * @return {Promise<void>} * * Appium: support Android and iOS */ async swipeRight(locator, xoffset = 1000, speed) { onlyForApps.call(this) if (!speed) { speed = xoffset xoffset = 100 } return this.swipe(parseLocator.call(this, locator), xoffset, 0, speed) } /** * Perform a swipe up on an element. * * ```js * let locator = "#io.selendroid.testapp:id/LinearLayout1"; * I.swipeUp(locator); // simple swipe * I.swipeUp(locator, 500); // set speed * I.swipeUp(locator, 1200, 1000); // set offset and speed * ``` * * @param {CodeceptJS.LocatorOrString} locator * @param {number} [yoffset] (optional) * @param {number} [speed=1000] (optional), 1000 by default * @return {Promise<void>} * * Appium: support Android and iOS */ async swipeUp(locator, yoffset = 1000, speed) { onlyForApps.call(this) if (!speed) { speed = yoffset yoffset = 100 } return this.swipe(parseLocator.call(this, locator), 0, -yoffset, speed) } /** * Perform a swipe in selected direction on an element to searchable element. * * ```js * I.swipeTo( * "android.widget.CheckBox", // searchable element * "//android.widget.ScrollView/android.widget.LinearLayout", // scroll element * "up", // direction * 30, * 100, * 500); * ``` * * @param {string} searchableLocator * @param {string} scrollLocator * @param {string} direction * @param {number} timeout * @param {number} offset * @param {number} speed * @return {Promise<void>} * * Appium: support Android and iOS */ async swipeTo(searchableLocator, scrollLocator, direction, timeout, offset, speed) { onlyForApps.call(this) direction = direction || 'down' switch (direction) { case 'down': direction = 'swipeDown' break case 'up': direction = 'swipeUp' break case 'left': direction = 'swipeLeft' break case 'right': direction = 'swipeRight' break } timeout = timeout || this.options.waitForTimeoutInSeconds const errorMsg = `element ("${searchableLocator}") still not visible after ${timeout}seconds` const browser = this.browser let err = false let currentSource return browser .waitUntil( () => { if (err) { return new Error(`Scroll to the end and element ${searchableLocator} was not found`) } return browser .$$(parseLocator.call(this, searchableLocator)) .then(els => els.length && els[0].isDisplayed()) .then(res => { if (res) { return true } return this[direction](scrollLocator, offset, speed) .getSource() .then(source => { if (source === currentSource) { err = true } else { currentSource = source return false } }) }) }, timeout * 1000, errorMsg, ) .catch(e => { if (e.message.indexOf('timeout') && e.type !== 'NoSuchElement') { throw new AssertionFailedError({ customMessage: `Scroll to the end and element ${searchableLocator} was not found` }, '') } else { throw e } }) } /** * Performs a specific touch action. * The action object need to contain the action name, x/y coordinates * * ```js * I.touchPerform([{ * action: 'press', * options: { * x: 100, * y: 200 * } * }, {action: 'release'}]) * * I.touchPerform([{ * action: 'tap', * options: { * element: '1', // json web element was queried before * x: 10, // x offset * y: 20, // y offset * count: 1 // number of touches * } * }]); * ``` * * Appium: support Android and iOS * * @param {Array} actions Array of touch actions */ async touchPerform(actions) { onlyForApps.call(this) return this.browser.touchPerform(actions) } /** * Pulls a file from the device. * * ```js * I.pullFile('/storage/emulated/0/DCIM/logo.png', 'my/path'); * // save file to output dir * I.pullFile('/storage/emulated/0/DCIM/logo.png', output_dir); * ``` * * @param {string} path * @param {string} dest * @return {Promise<string>} * * Appium: support Android and iOS */ async pullFile(path, dest) { onlyForApps.call(this) return this.browser.pullFile(path).then(res => fs.writeFile(dest, Buffer.from(res, 'base64'), err => { if (err) { return false } return true }), ) } /** * Perform a shake action on the device. * * ```js * I.shakeDevice(); * ``` * * @return {Promise<void>} * * Appium: support only iOS */ async shakeDevice() { onlyForApps.call(this, 'iOS') return this.browser.shake() } /** * Perform a rotation gesture centered on the specified element. * * ```js * I.rotate(120, 120) * ``` * * See corresponding [webdriverio reference](http://webdriver.io/api/mobile/rotate.html). * * @return {Promise<void>} * * Appium: support only iOS */ async rotate(x, y, duration, radius, rotation, touchCount) { onlyForApps.call(this, 'iOS') return this.browser.rotate(x, y, duration, radius, rotation, touchCount) } /** * Set immediate value in app. * * See corresponding [webdriverio reference](http://webdriver.io/api/mobile/setImmediateValue.html). * * @return {Promise<void>} * * Appium: support only iOS */ async setImmediateValue(id, value) { onlyForApps.call(this, 'iOS') return this.browser.setImmediateValue(id, value) } /** * Simulate Touch ID with either valid (match == true) or invalid (match == false) fingerprint. * * ```js * I.touchId(); // simulates valid fingerprint * I.touchId(true); // simulates valid fingerprint * I.touchId(false); // simulates invalid fingerprint * ``` * * @return {Promise<void>} * * Appium: support only iOS * TODO: not tested */ async simulateTouchId(match) { onlyForApps.call(this, 'iOS') match = match || true return this.browser.touchId(match) } /** * Close the given application. * * ```js * I.closeApp(); * ``` * * @return {Promise<void>} * * Appium: support both Android and iOS */ async closeApp() { onlyForApps.call(this) return this.browser.closeApp() } /** * {{> appendField }} * */ async appendField(field, value) { if (this.isWeb) return super.appendField(field, value) return super.appendField(parseLocator.call(this, field), value) } /** * {{> checkOption }} * */ async checkOption(field) { if (this.isWeb) return super.checkOption(field) return super.checkOption(parseLocator.call(this, field)) } /** * {{> click }} * */ async click(locator, context) { if (this.isWeb) return super.click(locator, context) const { elementId } = await this.browser.$(parseLocator.call(this, locator), parseLocator.call(this, context)) return this.axios({ method: 'post', url: `${this._buildAppiumEndpoint()}/element/${elementId}/click`, data: {}, }) } /** * {{> dontSeeCheckboxIsChecked }} * */ async dontSeeCheckboxIsChecked(field) { if (this.isWeb) return super.dontSeeCheckboxIsChecked(field) return super.dontSeeCheckboxIsChecked(parseLocator.call(this, field)) } /** * {{> dontSeeElement }} */ async dontSeeElement(locator) { if (this.isWeb) return super.dontSeeElement(locator) return super.dontSeeElement(parseLocator.call(this, locator)) } /** * {{> dontSeeInField }} * */ async dontSeeInField(field, value) { const _value = typeof value === 'boolean' ? value : value.toString() if (this.isWeb) return super.dontSeeInField(field, _value) return super.dontSeeInField(parseLocator.call(this, field), _value) } /** * {{> dontSee }} */ async dontSee(text, context = null) { if (this.isWeb) return super.dontSee(text, context) return super.dontSee(text, parseLocator.call(this, context)) } /** * {{> fillField }} * */ async fillField(field, value) { value = value.toString() if (this.isWeb) return super.fillField(field, value) return super.fillField(parseLocator.call(this, field), value) } /** * {{> grabTextFromAll }} * */ async grabTextFromAll(locator) { if (this.isWeb) return super.grabTextFromAll(locator) return super.grabTextFromAll(parseLocator.call(this, locator)) } /** * {{> grabTextFrom }} * */ async grabTextFrom(locator) { if (this.isWeb) return super.grabTextFrom(locator) return super.grabTextFrom(parseLocator.call(this, locator)) } /** * {{> grabNumberOfVisibleElements }} */ async grabNumberOfVisibleElements(locator) { if (this.isWeb) return super.grabNumberOfVisibleElements(locator) return super.grabNumberOfVisibleElements(parseLocator.call(this, locator)) } /** * Can be used for apps only with several values ("contentDescription", "text", "className", "resourceId") * * {{> grabAttributeFrom }} */ async grabAttributeFrom(locator, attr) { if (this.isWeb) return super.grabAttributeFrom(locator, attr) return super.grabAttributeFrom(parseLocator.call(this, locator), attr) } /** * Can be used for apps only with several values ("contentDescription", "text", "className", "resourceId") * {{> grabAttributeFromAll }} */ async grabAttributeFromAll(locator, attr) { if (this.isWeb) return super.grabAttributeFromAll(locator, attr) return super.grabAttributeFromAll(parseLocator.call(this, locator), attr) } /** * {{> grabValueFromAll }} * */ async grabValueFromAll(locator) { if (this.isWeb) return super.grabValueFromAll(locator) return super.grabValueFromAll(parseLocator.call(this, locator)) } /** * {{> grabValueFrom }} * */ async grabValueFrom(locator) { if (this.isWeb) return super.grabValueFrom(locator) return super.grabValueFrom(parseLocator.call(this, locator)) } /** * Saves a screenshot to ouput folder (set in codecept.conf.ts or codecept.conf.js). * Filename is relative to output folder. * * ```js * I.saveScreenshot('debug.png'); * ``` * * @param {string} fileName file name to save. * @return {Promise<void>} */ async saveScreenshot(fileName) { return super.saveScreenshot(fileName, false) } /** * {{> scrollIntoView }} * * Supported only for web testing */ async scrollIntoView(locator, scrollIntoViewOptions) { if (this.isWeb) return super.scrollIntoView(locator, scrollIntoViewOptions) } /** * {{> seeCheckboxIsChecked }} * */ async seeCheckboxIsChecked(field) { if (this.isWeb) return super.seeCheckboxIsChecked(field) return super.seeCheckboxIsChecked(parseLocator.call(this, field)) } /** * {{> seeElement }} * */ async seeElement(locator) { if (this.isWeb) return super.seeElement(locator) return super.seeElement(parseLocator.call(this, locator)) } /** * {{> seeInField }} * */ async seeInField(field, value) { const _value = typeof value === 'boolean' ? value : value.toString() if (this.isWeb) return super.seeInField(field, _value) return super.seeInField(parseLocator.call(this, field), _value) } /** * {{> see }} * */ async see(text, context) { if (this.isWeb) return super.see(text, context) return super.see(text, parseLocator.call(this, context)) } /** * {{> selectOption }} * * Supported only for web testing */ async selectOption(select, option) { if (this.isWeb) return super.selectOption(select, option) throw new Error("Should be used only in Web context. In native context use 'click' method instead") } /** * {{> waitForElement }} * */ async waitForElement(locator, sec = null) { if (this.isWeb) return super.waitForElement(locator, sec) return super.waitForElement(parseLocator.call(this, locator), sec) } /** * {{> waitForVisible }} * */ async waitForVisible(locator, sec = null) { if (this.isWeb) return super.waitForVisible(locator, sec) return super.waitForVisible(parseLocator.call(this, locator), sec) } /** * {{> waitForInvisible }} * */ async waitForInvisible(locator, sec = null) { if (this.isWeb) return super.waitForInvisible(locator, sec) return super.waitForInvisible(parseLocator.call(this, locator), sec) } /** * {{> waitForText }} * */ async waitForText(text, sec = null, context = null) { if (this.isWeb) return super.waitForText(text, sec, context) return super.waitForText(text, sec, parseLocator.call(this, context)) } } function parseLocator(locator) { if (!locator) return null if (typeof locator === 'object') { if (locator.web && this.isWeb) { return parseLocator.call(this, locator.web) } if (locator.android && this.platform === 'android') { if (typeof locator.android === 'string') { return parseLocator.call(this, locator.android) } // The locator is an Android DataMatcher or ViewMatcher locator so return as is return locator.android } if (locator.ios && this.platform === 'ios') { return parseLocator.call(this, locator.ios) } } if (typeof locator === 'string') { if (locator[0] === '~') return locator if (locator.substr(0, 2) === '//') return locator if (locator[0] === '#' && !this.isWeb) { // hook before webdriverio supports native # locators return parseLocator.call(this, { id: locator.slice(1) }) } if (this.platform === 'android' && !this.isWeb) { const isNativeLocator = /^\-?android=?/.exec(locator) return isNativeLocator ? locator : `android=new UiSelector().text("${locator}")` } } locator = new Locator(locator, 'xpath') if (locator.type === 'css' && !this.isWeb) throw new Error('Unable to use css locators in apps. Locator strategies for this request: xpath, id, class name or accessibility id') if (locator.type === 'name' && !this.isWeb) throw new Error("Can't locate element by name in Native context. Use either ID, class name or accessibility id") if (locator.type === 'id' && !this.isWeb && this.platform === 'android') return `//*[@resource-id='${locator.value}']` return locator.simplify() } // in the end of a file function onlyForApps(expectedPlatform) { const stack = new Error().stack || '' const re = /Appium.(\w+)/g const caller = stack.split('\n')[2].trim() const m = re.exec(caller) if (!m) { throw new Error(`Invalid caller ${caller}`) } const callerName = m[1] || m[2] if (!expectedPlatform) { if (!this.platform) { throw new Error(`${callerName} method can be used only with apps`) } } else if (this.platform !== expectedPlatform.toLowerCase()) { throw new Error(`${callerName} method can be used only with ${expectedPlatform} apps`) } } module.exports = Appium