diff --git a/.config/device.json b/.config/device.json index 2f90285..25883e7 100644 --- a/.config/device.json +++ b/.config/device.json @@ -2,6 +2,5 @@ "platform": "iOS", "platformVersion": "17.5", "deviceName": "iPhone 12", - "automationName": "XCUITest", "app": "/Users/thomasdsilva/Library/Developer/Xcode/DerivedData/Build/Products/Test-iphonesimulator/xyz.app" } diff --git a/README.md b/README.md index 96848d4..fdaeee5 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,34 @@ Sdcard: 512 MB ## Debugging +### 1. Address Security-Related Error + +Error Description: + +```WebDriverError: An unknown server-side error occurred while processing the command. +Original error: Potentially insecure feature 'adb_screen_streaming' has not been enabled. +If you want to enable this feature and accept the security ramifications, please do so by following the documented instructions at http://appium.io/docs/en/2.0/guides/security/ +``` + +#### Fix: + +1. Allow insecure feature in Appium: When starting the Appium server, you need to use the --allow-insecure=adb_screen_streaming flag. This flag allows insecure features, including screen streaming. + +### 2. Error: "The 'gst-inspect-1.0' binary is not available in the PATH" + +Error Description: + +```WebDriverError: An unknown server-side error occurred while processing the command. +Original error: The 'gst-inspect-1.0' binary is not available in the PATH on the host system. +See https://gstreamer.freedesktop.org/documentation/installing/index.html for more details on how to install it. +``` + +#### Fix: + +1. Install GStreamer +2. Add GStreamer to PATH if Necessary +3. Restart the appium server + ### launch.json Sample launch.json file for easily starting to debug the code on any device or configuration diff --git a/appium/actions/ActionsBase.js b/appium/actions/ActionsBase.js index 90326c7..e389bb7 100644 --- a/appium/actions/ActionsBase.js +++ b/appium/actions/ActionsBase.js @@ -1,3 +1,4 @@ +// actions/ActionsBase.js class ActionsBase { constructor(driver) { this._driver = driver diff --git a/appium/actions/Android.js b/appium/actions/Android.js index eb264f6..8f5cc13 100644 --- a/appium/actions/Android.js +++ b/appium/actions/Android.js @@ -1,7 +1,148 @@ +// actions/Android.js const ActionsBase = require('./ActionsBase') class Android extends ActionsBase { + async activate(appId) { + return this.driver.executeScript('mobile: activateApp', [{ appId }]) + } + async queryAppState(appId) { + return this.driver.executeScript('mobile: queryAppState', [{ appId }]) + } + + async terminate(appId) { + return this.driver.executeScript('mobile: terminateApp', [{ appId }]) + } + + async uninstall(appId) { + return this.driver.executeScript('mobile: removeApp', [{ appId }]) + } + + async source() { + return this.driver.executeScript('mobile: source', [{ format: 'xml' }]) + } + + async clipboard() { + return Buffer.from(await this.driver.getClipboard(), 'base64').toString( + 'ascii', + ) + } + + async startScreenRecording() { + return this.driver.executeScript('mobile: startScreenStreaming', [{}]) + } + + async stopScreenRecording() { + return this.driver.executeScript('mobile: stopScreenStreaming', [{}]) + } + + async screenshot() { + return this.driver.takeScreenshot() + } + + async elementScreenshot(elementId) { + return this.driver.takeElementScreenshot(elementId) + } + + async tap(elementId, duration = 200) { + return this.driver.executeScript('mobile: clickGesture', [ + { + elementId, + duration, + }, + ]) + } + + async longtap(elementId, duration = 1600) { + return this.driver.executeScript('mobile: longClickGesture', [ + { + elementId, + duration, + }, + ]) + } + + async write(elementId, value) { + return this.driver.elementSendKeys(elementId, value) + } + + async sendKeys(keys) { + return this.driver.executeScript('mobile: type', [{ keys }]) + } + + async clear(elementId) { + return this.driver.elementClear(elementId) + } + + async getAttribute(elementId, attribute) { + return this.driver.getElementAttribute(elementId, attribute) + } + + async dragAndDrop(elementId, fromX, fromY, toX, toY, speed = 1000) { + return this.driver.executeScript('mobile: dragGesture', [ + { + elementId, + startX: fromX, + startY: fromY, + endX: toX, + endY: toY, + speed, + }, + ]) + } + + async swipe(elementId, direction, percent = 0.8) { + if (!['up', 'down', 'left', 'right'].includes(direction)) { + throw new Error( + "Invalid swipe direction. Use 'up', 'down', 'left', or 'right'.", + ) + } + + return this.driver.executeScript('mobile: swipeGesture', [ + { + elementId, + direction, + percent, + }, + ]) + } + + async scrollTo(elementId, strategy = 'accessibility id', maxSwipes = 10) { + return this.driver.executeScript('mobile: scroll', [ + { + elementId, + strategy, + maxSwipes, + }, + ]) + } + + async deepLink(url) { + return this.driver.executeScript('mobile: deepLink', [{ url }]) + } + + async pushFile(filePath, payLoad) { + return this.driver.executeScript('mobile: pushFile', [ + { + remotePath: filePath, + payload: payLoad, + }, + ]) + } + + async pullFile(remotePath) { + return this.driver.executeScript('mobile: pullFile', [{ remotePath }]) + } + + async pressKeyCode(keyCode) { + this.driver.pressKeyCode(keyCode) + } + + async search() { + this.driver.executeScript('mobile: performEditorAction', [ + { action: 'search' }, + ]) + } } -module.exports = Android \ No newline at end of file +module.exports = Android diff --git a/appium/actions/iOS.js b/appium/actions/iOS.js index c84795d..d66bfb3 100644 --- a/appium/actions/iOS.js +++ b/appium/actions/iOS.js @@ -1,3 +1,4 @@ +// acions/iOS.js const ActionsBase = require('./ActionsBase') class iOS extends ActionsBase { diff --git a/appium/actions/index.js b/appium/actions/index.js index 39872f8..33f8dcb 100644 --- a/appium/actions/index.js +++ b/appium/actions/index.js @@ -1,3 +1,4 @@ +// actions/index.js const device = require('@nodebug/config')('device') const iOS = require('./iOS') const Android = require('./Android') diff --git a/appium/actions/macOS.js b/appium/actions/macOS.js index d296225..0541d37 100644 --- a/appium/actions/macOS.js +++ b/appium/actions/macOS.js @@ -1,3 +1,4 @@ +// actions/macOS.js const ActionsBase = require('./ActionsBase') class macOS extends ActionsBase { diff --git a/appium/capabilities/android.js b/appium/capabilities/android.js index 1984f3d..13370e5 100644 --- a/appium/capabilities/android.js +++ b/appium/capabilities/android.js @@ -1,14 +1,20 @@ +// capabilities/android.js + class Android { static capabilities(server, capability) { + const deviceName = capability.deviceName || 'pixel_6' + const platformVersion = capability.platformVersion || '12.0' + const app = capability.app || '' + return { - platformName: capability.platformName, - 'appium:deviceName': capability.deviceName, - 'appium:platformVersion': capability.platformVersion, - 'appium:automationName': capability.automationName, - 'appium:app': capability.app, + platformName: 'Android', + 'appium:deviceName': deviceName, + 'appium:platformVersion': platformVersion, + 'appium:automationName': 'UiAutomator2', + 'appium:app': app, 'appium:enforceXPath1': true, } } } -module.exports = Android \ No newline at end of file +module.exports = Android diff --git a/appium/capabilities/index.js b/appium/capabilities/index.js index 52039d3..5eae0aa 100644 --- a/appium/capabilities/index.js +++ b/appium/capabilities/index.js @@ -1,7 +1,9 @@ +// cababilities/index.js + const appium = require('@nodebug/config')('appium') const device = require('@nodebug/config')('device') -const iOS = require('./iOS') -const Android = require('./Android') +const iOS = require('./ios') +const Android = require('./android') const macOS = require('./macOS') function Capabilities(server = appium, capability = device) { diff --git a/appium/capabilities/ios.js b/appium/capabilities/ios.js index fda6b2f..7d552b6 100644 --- a/appium/capabilities/ios.js +++ b/appium/capabilities/ios.js @@ -1,3 +1,5 @@ +// cababilities/ios.js + class iOS { static capabilities(server, capability) { const deviceName = capability.deviceName || 'iPhone 12' diff --git a/appium/capabilities/macOS.js b/appium/capabilities/macOS.js index 14967a8..a0580c9 100644 --- a/appium/capabilities/macOS.js +++ b/appium/capabilities/macOS.js @@ -1,3 +1,5 @@ +// capabilities/macOS.js + class macOS { static capabilities(server, capability) { const timeout = server.timeout || 10 diff --git a/appium/device/Android.js b/appium/device/Android.js index 59280fb..80d22b4 100644 --- a/appium/device/Android.js +++ b/appium/device/Android.js @@ -1,7 +1,248 @@ +// device/Android.js + +const { log } = require('@nodebug/logger') +const fs = require('fs').promises +const path = require('path') const DeviceBase = require('./DeviceBase') class Android extends DeviceBase { - + async activate(packageName) { + const appId = packageName || this.capabilities['appium:appId'] + if ([undefined, null].includes(appId)) { + throw new Error( + 'PackageName should be passed as function parameter or should be declated in config file:device.json', + ) + } + log.info(`Activating app with package name ${appId}`) + try { + await this.actions.activate(appId) + await this.sleep(this.timeout * 200) + if ((await this.actions.queryAppState(appId)) === 4) { + log.info(`App ${appId} is activated and moved to foreground`) + return true + } + const message = `App ${appId} is not activated and not moved to foreground` + log.error(message) + throw new Error(message) + } catch (err) { + log.error(`Error while activating the app ${appId} to foreground`) + log.error(err.stack) + err.message = `Error while activating the app ${appId} to foreground.\n${err.message}` + throw err + } + } + + async terminate(packageName) { + const appId = packageName || this.capabilities['appium:appId'] + if ([undefined, null].includes(appId)) { + throw new Error( + 'PackageName should be passed as function parameter or should be declated in config file:device.json', + ) + } + log.info(`Terminating app with package name ${appId}`) + try { + await this.actions.terminate(appId) + await this.sleep(this.timeout * 200) + if ((await this.actions.queryAppState(appId)) === 1) { + log.info(`App ${appId} is terminated successfully`) + return true + } + log.error(`App ${appId} is still active`) + throw new Error(`Could not terminate App ${appId}`) + } catch (err) { + log.error(`Error while terminating the app ${appId}`) + log.error(err.stack) + err.message = `Error while terminating the app ${appId}.\n${err.message}` + throw err + } + } + + async uninstall(packageName) { + const appId = packageName || this.capabilities['appium:appId'] + if ([undefined, null].includes(appId)) { + throw new Error( + 'PackageName should be passed as function parameter or should be declated in config file:device.json', + ) + } + log.info(`Uninstalling app with package name ${appId}`) + try { + await this.actions.uninstall(appId) + await this.sleep(this.timeout * 200) + log.info(`App ${appId} is uninstalled`) + return true + } catch (err) { + if (this.driver !== undefined) { + log.error(`Error while uninstalling the app ${appId}`) + log.error(err.stack) + err.message = `Error while uninstalling the app ${appId}.\n${err.message}` + throw err + } else { + log.warn( + `Tried uninstalling the app ${appId}, but appium driver is not available or started.`, + ) + return true + } + } + } + + async clipboard() { + log.info(`Getting data from clipboard`) + try { + return this.actions.clipboard() + } catch (err) { + if (this.driver !== undefined) { + log.error(`Error while getting the contents of device clipboard`) + log.error(err.stack) + err.message = `Error while getting the contents of device clipboard.\n${err.message}` + throw err + } else { + log.warn( + `Tried getting the contents of device clipboard, but appium driver is not available or started.`, + ) + return true + } + } + } + + async source() { + log.info(`Getting the app source code`) + try { + return this.actions.source() + } catch (err) { + if (this.driver !== undefined) { + log.error(`Error while getting app source code`) + log.error(err.stack) + err.message = `Error while getting app source code.\n${err.message}` + throw err + } else { + log.warn( + `Tried getting the app source code, but appium driver is not available or started.`, + ) + return true + } + } + } + + async stopScreenRecording() { + log.info(`Stopping the screen recording`) + const video = await this.actions.stopScreenRecording() + await fs.writeFileSync(this.videoPath, video.payload, 'base64') + } + + async startScreenRecording() { + log.info(`Starting the screen recording`) + return this.actions.startScreenRecording() + } + + async openDeepLink(url, packageName) { + const appId = packageName || this.capabilities['appium:appId'] + if ([undefined, null].includes(appId)) { + throw new Error( + 'PackageName should be passed as function parameter or should be declared in config file:device.json', + ) + } + log.info(`Opening the deep link ${url}`) + let s + try { + await this.actions.deepLink(url) + await this.sleep(this.timeout * 200) + s = await this.actions.queryAppState(appId) + } catch (err) { + const message = `Error while opening the deep link ${url} for app ${appId}.` + log.error(message) + err.message = `${message}\n${err.message}` + throw err + } + + if (s !== 4) { + throw new Error( + `Deep link ${url} did not open app. The app ${appId} was not activated by the deep link.`, + ) + } else { + return true + } + } + + async pullFile(packageName, filePath) { + const appId = packageName || this.capabilities['appium:appId'] + if ([undefined, null].includes(appId)) { + throw new Error( + 'PackageName should be passed as function parameter or should be declared in config file:device.json', + ) + } + log.info(`Getting the file from path ${path}`) + try { + return this.actions.pullFile(appId, filePath) + } catch (err) { + const message = `Error while getting the file from path ${path} for app ${appId}.` + log.error(message) + err.message = `${message}\n${err.message}` + throw err + } + } + + async pushFile(remotePath, folderName, fileName) { + log.info(`Pushing the file to path ${remotePath}`) + try { + if ( + !remotePath || + typeof remotePath !== 'string' || + remotePath.trim() === '' || + remotePath.startsWith('/') || + remotePath.endsWith('/') + ) { + throw new Error( + 'Invalid remotePath provided. The remote path should not be empty, start or end with a slash (e.g., path/to/file)', + ) + } + + if ( + !folderName || + typeof folderName !== 'string' || + folderName.trim() === '' || + folderName.startsWith('/') || + folderName.endsWith('/') + ) { + throw new Error( + 'Invalid folder name provided. The folder name should not be empty, start or end with a slash (e.g., folder/folder)', + ) + } + + const fileExtensionRegex = /\.[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*$/ + if ( + !fileName || + typeof fileName !== 'string' || + fileName.trim() === '' || + !fileExtensionRegex.test(fileName) + ) { + throw new Error( + 'Invalid fileName provided. The file name should not be empty and must include a valid file extension (e.g., .txt, .jpg, .tar.gz)', + ) + } + + if (!fileName || typeof fileName !== 'string' || fileName.trim() === '') { + throw new Error('Invalid File Name provided') + } + const filepath = path.join(process.cwd(), `/${folderName}/`, fileName) + const fileBuffer = await fs.readFile(filepath) + const payLoad = fileBuffer.toString('base64') + + await this.actions.pushFile(`${remotePath}/${fileName}`, payLoad) + return true + } catch (error) { + throw new Error(`Error pushing file: ${error.message}`) + } + } + + async back() { + log.info('Click on the device back button') + return this.actions.pressKeyCode(4) + } + + async search() { + log.info('Click on the search button on device keypad') + this.actions.search() + } } -module.exports = Android \ No newline at end of file +module.exports = Android diff --git a/appium/device/iOS.js b/appium/device/iOS.js index cb28fef..1631499 100644 --- a/appium/device/iOS.js +++ b/appium/device/iOS.js @@ -1,3 +1,5 @@ +// device/iOS.js + const { log } = require('@nodebug/logger') const fs = require('fs') const DeviceBase = require('./DeviceBase') diff --git a/appium/device/index.js b/appium/device/index.js index 644808e..0fe57e6 100644 --- a/appium/device/index.js +++ b/appium/device/index.js @@ -1,3 +1,5 @@ +// device/index.js + const appium = require('@nodebug/config')('appium') const device = require('@nodebug/config')('device') const iOS = require('./iOS') @@ -18,7 +20,9 @@ function Device(server = appium, capability = device) { return new Android(server, capability) default: - return new Error(`${capability.platform} is not a known platform name. Known platforms are 'iOS', 'macOS' and 'Android'`) + return new Error( + `${capability.platform} is not a known platform name. Known platforms are 'iOS', 'macOS' and 'Android'`, + ) } } diff --git a/appium/device/macOS.js b/appium/device/macOS.js index a02f190..d7ed37d 100644 --- a/appium/device/macOS.js +++ b/appium/device/macOS.js @@ -1,3 +1,5 @@ +// device/macOS.js + const { log } = require('@nodebug/logger') const fs = require('fs') const DeviceBase = require('./DeviceBase') diff --git a/appium/driver/Android.js b/appium/driver/Android.js index f8c0d27..f1c2779 100644 --- a/appium/driver/Android.js +++ b/appium/driver/Android.js @@ -1,16 +1,605 @@ +// driver/Android.js + +const { log } = require('@nodebug/logger') const Strategy = require('./Strategy') class Android extends Strategy { - async initiate() { - await this.device.stop() - await this.device.start() - await this.device.startScreenRecording() + async initiate() { + await this.device.stop() + await this.device.start() + await this.device.startScreenRecording() + return true + } + + async exit() { + await this.device.stopScreenRecording() + await this.device.stop() + return true + } + + async screenshot() { + let dataUrl = false + if (this.stack.length > 0) { + let locator + try { + locator = await this.finder() + } catch (err) { + log.error(err.stack) + } + if (![undefined, null, ''].includes(locator)) { + this.messenger({ stack: this.stack, action: 'screenshot' }) + dataUrl = await this.device.actions.elementScreenshot(locator.ELEMENT) + } + } + + if (!dataUrl) { + log.info('Capturing screenshot of device') + dataUrl = await this.device.actions.screenshot() + } + + this.stack = [] + return dataUrl + } + + element(data) { + const description = { exact: false, parent: false } + const newStack = [] + do { + const item = this.stack.pop() + + if (item) { + if (item.exact) { + description.exact = true + } else if (item.parent) { + description.parent = true + } else { + newStack.push(item) + } + } + } while ( + this.stack.some( + (item) => item.type !== 'element' && (item.exact || item.parent), + ) + ) + if (newStack.length > 0) { + this.stack.push(...newStack) + } + + const elementData = { + type: 'element', + id: data.toString(), + exact: description.exact, + parent: description.parent, + matches: [], + index: false, + visible: false, + } + + this.stack.push(elementData) + return this + } + + async longtap() { + this.message = this.messenger({ stack: this.stack, action: 'longtap' }) + this.stack[0].visible = true + try { + const locator = await this.finder() + await this.device.actions.longtap(locator.ELEMENT) + await this.waitToRecover(this.RECOVERY_TIME) + } catch (err) { + log.error( + `${this.message}\nError while tapping on element.\nError ${err.stack}`, + ) + this.stack = [] + err.message = `Error while ${this.message}\n${err.message}` + throw err + } + this.stack = [] + return true + } + + // async drop() { + // const a = [...this.stack] + // const b = a.findIndex((e) => e.type === 'drag') + + // const c = [...a].splice(0, b) + // const d = [...a][b + 1] + // const e = [...a].splice(b + 1, a.length) + // const f = [...a].splice(b + 2, a.length) + + // this.message = this.messenger({ stack: c, action: 'drag' }) + // this.message = `${this.message} and ${this.messenger({ + // stack: e, + // action: 'drop', + // })}` + + // try { + // this.stack = [...c] + // const g = await this.finder() + // this.stack = [...f] + // const h = await this.finder() + + // let i = {} + // switch (d) { + // case 'toLeftOf': + // i = { toX: h.rect.left } + // break + + // case 'toRightOf': + // i = { toX: h.rect.right } + // break + + // case 'above': + // i = { toY: h.rect.top } + // break + + // case 'below': + // i = { toY: h.rect.bottom } + // break + + // default: + // break + // } + + // const { fromX, fromY, toX, toY } = { + // fromX: g.rect.midx, + // fromY: g.rect.midy, + // toX: h.rect.left, + // toY: h.rect.midy, + // ...i, + // } + // await this.device.actions.dragAndDrop(fromX, fromY, toX, toY) + // await this.waitToRecover(this.RECOVERY_TIME) + // } catch (err) { + // log.error( + // `${this.message}\nError during drag and drop.\nError ${err.stack}`, + // ) + // this.stack = [] + // err.message = `Error while ${this.message}\n${err.message}` + // throw err + // } + + // this.stack = [] + // return true + // } + + async tap() { + this.message = this.messenger({ stack: this.stack, action: 'tap' }) + this.stack[0].visible = true + try { + const locator = await this.finder() + await this.device.actions.tap(locator.ELEMENT) + await this.waitToRecover(this.RECOVERY_TIME) + } catch (err) { + log.error( + `${this.message}\nError while tapping on element.\nError ${err.stack}`, + ) + err.message = `Error while ${this.message}\n${err.message}` + this.stack = [] + throw err + } + this.stack = [] + return true + } + + async clear() { + this.message = this.messenger({ stack: this.stack, action: 'clear' }) + this.stack[0].visible = true + try { + const locator = await this.finder(null, 'write') + await this.device.actions.clear(locator.ELEMENT) + await this.waitToRecover(this.RECOVERY_TIME) + } catch (err) { + log.error(`${this.message}\nError clearing data.\nError ${err.stack}`) + this.stack = [] + err.message = `Error while ${this.message}\n${err.message}` + throw err + } + this.stack = [] + return true + } + + async write(data) { + this.message = this.messenger({ + stack: this.stack, + action: 'write', + data, + }) + this.stack[0].visible = true + try { + const locator = await this.finder(null, 'write') + await this.device.actions.write(locator.ELEMENT, data) + await this.waitToRecover(this.RECOVERY_TIME) + } catch (err) { + log.error( + `${this.message}\nError while entering data.\nError ${err.stack}`, + ) + this.stack = [] + err.message = `Error while ${this.message}\n${err.message}` + throw err + } + this.stack = [] + return true + } + + async isNotDisplayed(t = null) { + await this.sleep(1000) + this.message = this.messenger({ + stack: this.stack, + action: 'isNotDisplayed', + }) + let timeout + if (t === null) { + timeout = this.timeout * 1000 + } else { + timeout = t + } + + const now = await Date.now() + /* eslint-disable no-await-in-loop */ + while (Date.now() < now + timeout) { + try { + let locators = await this.device.elements.findAll(this.stack) + if (locators.length === 0) { + throw new Error('0 matching elements found') + } else { + locators = await Promise.all( + locators.map(async (locator) => + this.device.actions.getAttribute(locator.ELEMENT, 'visible'), + ), + ) + if (!locators.includes('true')) { + throw new Error('0 matching elements found') + } + } + } catch (err) { + log.info('Element is not visible on screen') + this.stack = [] + return true + } + } + /* eslint-enable no-await-in-loop */ + log.error(`${this.message}\nElement is visible on screen`) + this.stack = [] + throw new Error(`Error while ${this.message}\nElement is visible on screen`) + } + + async isDisplayed(t = null) { + this.message = this.messenger({ stack: this.stack, action: 'isDisplayed' }) + try { + await this.finder(t) + } catch (err) { + log.error( + `${this.message}\nElement is not visible on screen\n${err.message}`, + ) + this.stack = [] + err.message = `Error while ${this.message}\n${err.message}` + throw err + } + log.info('Element is visible on screen') + this.stack = [] + return true + } + + async isVisible(t = null) { + this.message = this.messenger({ stack: this.stack, action: 'isVisible' }) + let e + try { + e = await this.finder(t) + } catch (err) { + log.info(err.message) + } + + this.stack = [] + if (![null, undefined, ''].includes(e)) { + log.info('Element is visible on screen') + return true + } + log.info('Element is not visible on screen') + return false + } + + async isEnabled() { + this.message = this.messenger({ stack: this.stack, action: 'isEnabled' }) + try { + const locator = await this.finder() + const v = JSON.parse( + await this.device.actions.getAttribute(locator.ELEMENT, 'enabled'), + ) + if (v) { + log.info('Element is enabled') + } else { + log.info('Element is disabled') + } + this.stack = [] + return v + } catch (err) { + log.error(`Error while ${this.message}\nError ${err.stack}`) + this.stack = [] + err.message = `Error while ${this.message}\n${err.message}` + throw err + } + } + + async isSelected() { + this.message = this.messenger({ stack: this.stack, action: 'isSelected' }) + try { + const locator = await this.finder() + const v = + (await this.device.actions.getAttribute( + locator.ELEMENT, + 'selected', + )) === 'true' + if (v) { + log.info('Element is selected') + } else { + log.info('Element is not selected') + } + this.stack = [] + return v + } catch (err) { + log.error(`Error while ${this.message}\nError ${err.stack}`) + this.stack = [] + err.message = `Error while ${this.message}\n${err.message}` + throw err + } + } + + async text() { + this.message = this.messenger({ stack: this.stack, action: 'value' }) + try { + const locator = await this.finder() + const v = await this.device.actions.getAttribute(locator.ELEMENT, 'text') + log.info(`Text is ${v}`) + this.stack = [] + return v + } catch (err) { + log.error(`Error while ${this.message}\nError ${err.stack}`) + this.stack = [] + err.message = `Error while ${this.message}\n${err.message}` + throw err + } + } + + async finder(t = null, action = null) { + let timeout + if (t === null) { + timeout = this.timeout * 1000 + } else { + timeout = t + } + + const now = await Date.now() + let locator + while (Date.now() < now + timeout && locator === undefined) { + try { + // eslint-disable-next-line no-await-in-loop + locator = await this.device.elements.find(this.stack, action) + } catch (err) { + // eslint-disable-next-line no-empty + if (!err.message.includes('has no matching elements on screen')) { + log.warn(err.stack) + } + } } - async exit() { - await this.device.stopScreenRecording() - await this.device.stop() + if (locator === undefined) { + throw new Error( + `Element was not found on screen after ${timeout} timeout`, + ) } + return locator + } + + async findAll() { + this.message = this.messenger({ stack: this.stack, action: 'findAll' }) + let locators = [] + try { + try { + await this.finder() + } catch (err) { + log.info('No matching elements are visible on screen.') + } + locators = await this.device.elements.findAll(this.stack) + } catch (err) { + log.error( + `${this.message}\nError while finding all matching elements.\n${err.message}`, + ) + this.stack = [] + err.message = `Error while ${this.message}\n${err.message}` + throw err + } + this.stack = [] + return locators + } + + async find() { + this.message = this.messenger({ stack: this.stack, action: 'find' }) + let locator + try { + locator = await this.finder() + } catch (err) { + log.info(err.message) + } + + this.stack = [] + if (![null, undefined, ''].includes(locator)) { + log.info('Element is visible on screen') + return locator + } + log.info('Element is not visible on screen') + throw new Error( + `Error while ${this.message}\nElement is not visible on screen`, + ) + } + + async swipeUp() { + this.message = this.messenger({ stack: this.stack, action: 'swipeUp' }) + const element = await this.device.driver.findElement( + 'xpath', + '(//android.view.View)[3]', + ) + await this.device.actions.swipe(element.elementId, 'up') + await this.waitToRecover(this.RECOVERY_TIME) + return true + } + + async swipeDown() { + this.message = this.messenger({ stack: this.stack, action: 'swipeDown' }) + const element = await this.device.driver.findElement( + 'xpath', + '(//android.view.View)[3]', + ) + await this.device.actions.swipe(element.ELEMENT, 'down') + await this.waitToRecover(this.RECOVERY_TIME) + return true + } + + async swipeRight() { + this.message = this.messenger({ stack: this.stack, action: 'scrollRight' }) + const element = await this.device.driver.findElement( + 'xpath', + '(//android.view.View)[3]', + ) + await this.device.actions.swipe(element.elementId, 'right') + await this.waitToRecover(this.RECOVERY_TIME) + return true + } + + async swipeLeft() { + this.message = this.messenger({ stack: this.stack, action: 'scrollLeft' }) + const element = await this.device.driver.findElement( + 'xpath', + '(//android.view.View)[3]', + ) + await this.device.actions.swipe(element.elementId, 'right') + await this.waitToRecover(this.RECOVERY_TIME) + return true + } + + async scrollIntoView() { + this.message = this.messenger({ + stack: this.stack, + action: 'scrollIntoView', + }) + try { + const locator = await this.finder() + await this.device.actions.scrollTo(locator.ELEMENT) + await this.waitToRecover(this.RECOVERY_TIME) + await this.scrollAndBringToCenter() + } catch (err) { + log.warn( + `${this.message}\nError while scrolling to element\n${err.message}`, + ) + log.info('Now trying other scroll...') + await this.otherScroll() + } finally { + this.stack = [] + } + log.info('Scrolled element into view.') + return true + } + + // async scrollAndBringToCenter() { + // try { + // const locator = await this.finder() + // let e = await this.device.driver.findElement( + // 'xpath', + // '//XCUIElementTypeWindow', + // ) + // if (e.error === 'no such element') { + // log.info('Window is not accessible.') + // return false + // } + // e = await this.device.elements.addQualifiers(e) + // if (locator.rect.bottom > 0.8 * e.rect.bottom) { + // const { fromX, fromY, toX, toY } = { + // fromX: e.rect.midx, + // fromY: e.rect.midy, + // toX: e.rect.midx, + // toY: e.rect.midy - 100, + // } + // await this.device.actions.swipe(fromX, fromY, toX, toY) + // } else if (locator.rect.bottom < 0.3 * e.rect.bottom) { + // const { fromX, fromY, toX, toY } = { + // fromX: e.rect.midx, + // fromY: e.rect.midy, + // toX: e.rect.midx, + // toY: e.rect.midy + 100, + // } + // await this.device.actions.swipe(fromX, fromY, toX, toY) + // } + // await this.waitToRecover(this.RECOVERY_TIME) + // } catch (err) { + // log.err(`${err.message}\n${err.stack}`) + // throw err + // } + // return true + // } + + // async otherScroll() { + // try { + // let e = await this.device.driver.findElement( + // 'xpath', + // `//*[contains(@name, 'Vertical scroll bar')]/..`, + // ) + // if (e.error === 'no such element') { + // log.info( + // 'Screen is not scrollable as scroll bar is missing. Could not scroll to element.', + // ) + // return false + // } + // e = await this.device.elements.addQualifiers(e) + + // /* eslint-disable no-await-in-loop */ + // do { + // try { + // const locators = await this.device.elements.findAll(this.stack) + // if (locators.length > 0) { + // await this.device.actions.scrollTo(locators[0].ELEMENT) + // await this.waitToRecover(this.RECOVERY_TIME) + // log.info('Successfully scrolled offscreen element into view') + // break + // } + // } catch (err) { + // log.error(`Error while searching for elements. Err:${err.message}`) + // } + // log.info('Element is not visible on screen. Scrolling...') + // const press = await this.device.actions.source() + // const { fromX, fromY, toX, toY } = { + // fromX: e.rect.midx, + // fromY: e.rect.midy, + // toX: e.rect.x, + // toY: e.rect.y, + // } + // await this.device.actions.swipe(fromX, fromY, toX, toY) + // await this.waitToRecover(this.RECOVERY_TIME) + + // let count = 0 + // do { + // const j1 = await this.device.actions.source() + // const j2 = await this.device.actions.source() + // if (JSON.stringify(j1) === JSON.stringify(j2)) { + // break + // } + // count += 1 + // } while (count < 10) + // const postss = await this.device.actions.source() + // if (JSON.stringify(press) === JSON.stringify(postss)) { + // throw new Error('End of screen reached. Unable to scroll.') + // } + // // eslint-disable-next-line no-constant-condition + // } while (true) + // /* eslint-enable no-await-in-loop */ + // } catch (err) { + // err.message = `${this.message}\nError while scrolling to offscreen element.\n${err.message}.\nAlso, no matching elements were found offscreen.` + // log.error(err.message) + // this.stack = [] + // throw err + // } + + // return true + // } } -module.exports = Android \ No newline at end of file +module.exports = Android diff --git a/appium/driver/Strategy.js b/appium/driver/Strategy.js index 3e56134..c8162b1 100644 --- a/appium/driver/Strategy.js +++ b/appium/driver/Strategy.js @@ -1,3 +1,5 @@ +// driver/Strategy.js + const { log } = require('@nodebug/logger') const Device = require('../device') const Toggle = require('../toggle') @@ -69,6 +71,11 @@ class Strategy { return this.relativePositioner('toRightOf') } + parentOf() { + this.stack.push({ parent: true }) + return this + } + atIndex(index) { if (typeof index !== 'number') { throw new TypeError( diff --git a/appium/driver/iOS.js b/appium/driver/iOS.js index a55e956..622a9a0 100644 --- a/appium/driver/iOS.js +++ b/appium/driver/iOS.js @@ -1,3 +1,5 @@ +// driver/iOS.js + const { log } = require('@nodebug/logger') const Strategy = require('./Strategy') diff --git a/appium/driver/index.js b/appium/driver/index.js index 054dc84..0833b6c 100644 --- a/appium/driver/index.js +++ b/appium/driver/index.js @@ -1,23 +1,27 @@ +// driver/index.js + const iOS = require('./iOS') const macOS = require('./macOS') const Android = require('./Android') function Driver(server, capability) { - switch (capability.platform.toLowerCase()) { - case 'ios': - // eslint-disable-next-line new-cap - return new iOS(server, capability) + switch (capability.platform.toLowerCase()) { + case 'ios': + // eslint-disable-next-line new-cap + return new iOS(server, capability) - case 'macos': - // eslint-disable-next-line new-cap - return new macOS(server, capability) + case 'macos': + // eslint-disable-next-line new-cap + return new macOS(server, capability) - case 'android': - return new Android(server, capability) + case 'android': + return new Android(server, capability) - default: - return new Error(`${capability.platform} is not a known platform name. Known platforms are 'iOS', 'macOS' and 'Android'`) - } + default: + return new Error( + `${capability.platform} is not a known platform name. Known platforms are 'iOS', 'macOS' and 'Android'`, + ) + } } module.exports = Driver diff --git a/appium/driver/macOS.js b/appium/driver/macOS.js index f0460fb..8cef7a7 100644 --- a/appium/driver/macOS.js +++ b/appium/driver/macOS.js @@ -1,3 +1,5 @@ +// driver/macOS.js + const { log } = require('@nodebug/logger') const Strategy = require('./Strategy') diff --git a/appium/driver/messenger.js b/appium/driver/messenger.js index 0f1a3c8..771940f 100644 --- a/appium/driver/messenger.js +++ b/appium/driver/messenger.js @@ -1,11 +1,17 @@ +// driver/messenger.js + const { log } = require('@nodebug/logger') function messenger(a) { let message = '' if ( - ['isVisible', 'isDisplayed', 'isNotDisplayed', 'isEnabled'].includes( - a.action, - ) + [ + 'isVisible', + 'isDisplayed', + 'isNotDisplayed', + 'isSelected', + 'isEnabled', + ].includes(a.action) ) { message = `Checking ` } else if (a.action === 'click') { @@ -56,11 +62,16 @@ function messenger(a) { 'alert', 'cell', 'menuitem', + 'progressbar', + 'tab', ].includes(obj.type) ) { if (obj.exact) { message += 'exact ' } + if (obj.parent) { + message += 'parent of ' + } message += `${obj.type} '${obj.id}' ` if (obj.index) { message += `of index '${obj.index}' ` @@ -87,6 +98,12 @@ function messenger(a) { message += `to ${a.data} state` } else if (a.action === 'scrollIntoView') { message += `to be visible` + } else if (a.action === 'isSelected') { + message += `is selected` + } else if (a.action === 'scroll') { + message += `to be visible` + } else if (a.action === 'isChecked') { + message += `is checked` } log.info(message) diff --git a/appium/elements/Android.js b/appium/elements/Android.js index edd8cf2..bb4ffff 100644 --- a/appium/elements/Android.js +++ b/appium/elements/Android.js @@ -1,3 +1,5 @@ +// elements/Android.js + const ElementsBase = require('./ElementsBase') const Selectors = require('../selectors') @@ -6,6 +8,36 @@ class Android extends ElementsBase { const selectors = Selectors('android') super(driver, selectors) } + + getSelectors(obj) { + /* eslint-disable prefer-const */ + let xpath = this.selectors.getSelector(obj.id, obj.exact) + /* eslint-enable prefer-const */ + if (obj.parent) { + Object.keys(xpath).forEach((key) => { + if (typeof xpath[key] === 'string') { + xpath[key] = `${xpath[key]}/..` + } + }) + } + return xpath + } + + async addQualifiers(locator) { + const element = locator + element.rect = await this.driver.getElementRect(element.ELEMENT) + element.rect.left = element.rect.x + element.rect.right = element.rect.x + element.rect.width + element.rect.midx = element.rect.x + element.rect.width / 2 + element.rect.top = element.rect.y + element.rect.bottom = element.rect.y + element.rect.height + element.rect.midy = element.rect.y + element.rect.height / 2 + element.tagname = await this.driver.getElementTagName(element.ELEMENT) + element.visible = JSON.parse( + await this.driver.getElementAttribute(element.ELEMENT, 'displayed'), + ) + return element + } } module.exports = Android diff --git a/appium/elements/ElementsBase.js b/appium/elements/ElementsBase.js index b56bfe0..4754a1d 100644 --- a/appium/elements/ElementsBase.js +++ b/appium/elements/ElementsBase.js @@ -1,3 +1,5 @@ +// elements/ElementsBase.js + class ElementsBase { constructor(driver, selectors) { this._driver = driver diff --git a/appium/elements/iOS.js b/appium/elements/iOS.js index 5f79fd7..f0da444 100644 --- a/appium/elements/iOS.js +++ b/appium/elements/iOS.js @@ -1,3 +1,5 @@ +// elements/iOS.js + const ElementsBase = require('./ElementsBase') const Selectors = require('../selectors') @@ -6,6 +8,22 @@ class iOS extends ElementsBase { const selectors = Selectors('ios') super(driver, selectors) } + + async addQualifiers(locator) { + const element = locator + element.rect = await this.driver.getElementRect(element.ELEMENT) + element.rect.left = element.rect.x + element.rect.right = element.rect.x + element.rect.width + element.rect.midx = element.rect.x + element.rect.width / 2 + element.rect.top = element.rect.y + element.rect.bottom = element.rect.y + element.rect.height + element.rect.midy = element.rect.y + element.rect.height / 2 + element.tagname = await this.driver.getElementTagName(element.ELEMENT) + element.visible = JSON.parse( + await this.driver.getElementAttribute(element.ELEMENT, 'visible'), + ) + return element + } } module.exports = iOS diff --git a/appium/elements/index.js b/appium/elements/index.js index 4200caf..b18678c 100644 --- a/appium/elements/index.js +++ b/appium/elements/index.js @@ -1,3 +1,5 @@ +// element/index.js + const device = require('@nodebug/config')('device') const iOS = require('./iOS') const macOS = require('./macOS') @@ -17,7 +19,9 @@ function Elements(driver, platform = device.platform) { return new Android(driver) default: - return new Error(`${platform} is not a known platform name. Known platforms are 'iOS', 'macOS' and 'Android'`) + return new Error( + `${platform} is not a known platform name. Known platforms are 'iOS', 'macOS' and 'Android'`, + ) } } diff --git a/appium/elements/macOS.js b/appium/elements/macOS.js index 98c738f..6e5b9a5 100644 --- a/appium/elements/macOS.js +++ b/appium/elements/macOS.js @@ -1,3 +1,5 @@ +// elements/macOS.js + const ElementsBase = require('./ElementsBase') const Selectors = require('../selectors') diff --git a/appium/selectors/Android.js b/appium/selectors/Android.js index 8ad423d..3fb8666 100644 --- a/appium/selectors/Android.js +++ b/appium/selectors/Android.js @@ -1,20 +1,31 @@ +// selector/Android.js const SelectorsBase = require('./SelectorsBase') const tags = { - window: ['XCUIElementTypeWindow'], + window: ['android.widget.FrameLayout'], button: ['android.widget.Button'], - dialog: ['android.widget.Dialog'], - radio: ['android.widget.Radio'], + dialog: ['android.widget.AlertDialog'], + radio: ['android.widget.RadioButton'], image: ['android.widget.ImageView'], - switch: ['android.widget.Switch'], + switch: ['android.widget.Switch', 'android.view.View'], alert: ['androidx.appcompat.widget.LinearLayoutCompat'], cell: ['android.view.ViewGroup'], - menuitem: ['XCUIElementTypeMenuItem'], - textfield: ['android.widget.EditText'], + menuitem: ['android.view.MenuItem'], search: ['android.widget.AutoCompleteTextView'], + textfield: ['android.widget.EditText'], + progressbar: ['android.widget.ProgressBar'], + tab: ['android.widget.FrameLayout'], } -const attributes = ['name', 'label', 'value', 'text', 'resource-id'] +const attributes = [ + 'name', + 'label', + 'value', + 'text', + 'resource-id', + 'hint', + 'content-desc', +] class Android extends SelectorsBase { constructor() { super(attributes, tags) @@ -22,14 +33,16 @@ class Android extends SelectorsBase { getSelector(attribute, exact = false) { const str = this.matcher(attribute, exact) - return { button: `//*[(${str}) and ${SelectorsBase.self(this.tagnames.button)}]`, dialog: `//*[(${str}) and ${SelectorsBase.self(this.tagnames.dialog)}]`, - radio: `//*[(${str}) and ${SelectorsBase.self(this.tagnames.radio)}]`, + radio: `//*[(${str})]/ancestor-or-self::*[${SelectorsBase.self( + this.tagnames.radio, + )}]`, image: `//*[(${str}) and ${SelectorsBase.self(this.tagnames.image)}]`, - switch: `//*[(${str}) and ${SelectorsBase.self(this.tagnames.switch)}]`, - textbox: `//*[(${str}) and ${SelectorsBase.self(this.tagnames.textbox)}]`, + switch: `//*[(${str}) and ${SelectorsBase.self( + this.tagnames.switch, + )} and @resource-id='Toggle']`, alert: `//*[(${str})]/ancestor-or-self::*[${SelectorsBase.self( this.tagnames.alert, )}]`, @@ -39,6 +52,15 @@ class Android extends SelectorsBase { menuitem: `//*[(${str})]/ancestor-or-self::*[${SelectorsBase.self( this.tagnames.menuitem, )}]`, + textbox: `//*[(${str})]/ancestor-or-self::*[${SelectorsBase.self( + this.tagnames.textbox, + )}]`, + progressbar: `//*[(${str})]/ancestor-or-self::*[${SelectorsBase.self( + this.tagnames.progressbar, + )}]`, + tab: `//*[(${str})]/ancestor-or-self::*[${SelectorsBase.self( + this.tagnames.tab, + )}]`, element: `//*[${str}]`, } } diff --git a/appium/selectors/SelectorsBase.js b/appium/selectors/SelectorsBase.js index 4d1e0d5..5ff6480 100644 --- a/appium/selectors/SelectorsBase.js +++ b/appium/selectors/SelectorsBase.js @@ -1,3 +1,5 @@ +// selectors/SelectorsBase.js + class SelectorsBase { constructor(attributes, tags) { this.attributes = attributes @@ -18,6 +20,8 @@ class SelectorsBase { search: this.tags.search, textfield: this.tags.textfield, textbox: [].concat.apply([], [this.tags.textfield, this.tags.search]), + progressbar: this.tags.progressbar, + tab: this.tags.tab, } } diff --git a/appium/selectors/index.js b/appium/selectors/index.js index 2a894f6..f70ff4b 100644 --- a/appium/selectors/index.js +++ b/appium/selectors/index.js @@ -1,5 +1,5 @@ const device = require('@nodebug/config')('device') -const iOS = require('./iOS') +const iOS = require('./IOS') const macOS = require('./macOS') const Android = require('./Android') @@ -17,7 +17,9 @@ function Selectors(platform = device.platform) { return new Android() default: - return new Error(`${platform} is not a known platform name. Known platforms are 'iOS', 'macOS' and 'Android'`) + return new Error( + `${platform} is not a known platform name. Known platforms are 'iOS', 'macOS' and 'Android'`, + ) } } diff --git a/appium/toggle/Android.js b/appium/toggle/Android.js index 22d9079..2e397b4 100644 --- a/appium/toggle/Android.js +++ b/appium/toggle/Android.js @@ -1,3 +1,5 @@ +// toogle/Android.js + const { log } = require('@nodebug/logger') const ToggleBase = require('./ToggleBase') @@ -6,6 +8,66 @@ class Android extends ToggleBase { await this.that.device.actions.tap(elementId) log.info('Tapped on toggle.') } + + async action(state) { + this.that.message = this.that.messenger({ + stack: this.that.stack, + action: 'toggle', + data: state, + }) + + try { + const locator = await this.that.finder(null, 'toggle') + const currentState = await this.that.device.actions.getAttribute( + locator.ELEMENT, + 'checked', + ) + if ( + (state === 'ON' && currentState === 'false') || + (state === 'OFF' && currentState === 'true') + ) { + await this.performer(locator.ELEMENT) + await this.that.waitToRecover(this.that.RECOVERY_TIME) + + let newState + try { + const newLocator = await this.that.finder(null, 'toggle') + newState = await this.that.device.actions.getAttribute( + newLocator.ELEMENT, + 'checked', + ) + } catch (err) { + log.warn(`${this.that.message}\n${err.message}`) + log.info( + `Retrying to get value of toggle using ElementId before toggle was pressed on.`, + ) + newState = await this.that.device.actions.getAttribute( + locator.ELEMENT, + 'checked', + ) + } + + if ( + (state === 'ON' && newState === 'false') || + (state === 'OFF' && newState === 'true') + ) { + throw new Error(`Setting toggle to ${state} was not successful.`) + } + log.info(`Toggle set to ${state} state`) + } else { + log.info(`Toggle is already in ${state} state.`) + } + } catch (err) { + log.error( + `${this.that.message}\nError while toggling element to ${state} state.\nError ${err.stack}`, + ) + this.that.stack = [] + err.message = `Error while ${this.that.message}\n${err.message}` + throw err + } + this.that.stack = [] + return true + } } module.exports = Android diff --git a/appium/toggle/ToggleBase.js b/appium/toggle/ToggleBase.js index f772597..c567f92 100644 --- a/appium/toggle/ToggleBase.js +++ b/appium/toggle/ToggleBase.js @@ -1,3 +1,4 @@ +// toggle/ToggleBase.js const { log } = require('@nodebug/logger') class ToggleBase {