diff --git a/CHANGELOG.md b/CHANGELOG.md index f60915b..b89928a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +## [6.1.3](https://github.com/NativeScript/nativescript-dev-appium/compare/6.1.2...6.1.3) (2019-11-12) + + +### Bug Fixes + +* **ios:** apply getActualRectangle ([#267](https://github.com/NativeScript/nativescript-dev-appium/issues/267)) ([ebe92e0](https://github.com/NativeScript/nativescript-dev-appium/commit/ebe92e0)) +* compareElement to use proper rectangle and generate proper diff image. ([cd76798](https://github.com/NativeScript/nativescript-dev-appium/commit/cd76798)) +* resolve device type by device name ([#277](https://github.com/NativeScript/nativescript-dev-appium/issues/277)) ([e7d7345](https://github.com/NativeScript/nativescript-dev-appium/commit/e7d7345)) + + + + +## [6.1.2](https://github.com/NativeScript/nativescript-dev-appium/compare/6.1.0...6.1.2) (2019-10-04) + + +### Bug Fixes + +* **ios-13:** remove statusbar height from viewportRect ([c1a993c](https://github.com/NativeScript/nativescript-dev-appium/commit/c1a993c)) + + + + +# [6.1.0](https://github.com/NativeScript/nativescript-dev-appium/compare/6.0.0...6.1.0) (2019-10-03) + + +### Bug Fixes + +* deep clone of cropRect from options ([a10689e](https://github.com/NativeScript/nativescript-dev-appium/commit/a10689e)) +* prevent crashing in case device density is missing ([#256](https://github.com/NativeScript/nativescript-dev-appium/issues/256)) ([0d7d101](https://github.com/NativeScript/nativescript-dev-appium/commit/0d7d101)) +* respect appium capabilities provided by the user in the json file ([9485d32](https://github.com/NativeScript/nativescript-dev-appium/commit/9485d32)) +* saving every image when LogImageType.everyImage is provided ([0147399](https://github.com/NativeScript/nativescript-dev-appium/commit/0147399)) +* sendKeys command to input properly string with intervals. Add parameter for chars count to be deleted ([efc0932](https://github.com/NativeScript/nativescript-dev-appium/commit/efc0932)) +* **ios:** ios13 statusbar height ([#265](https://github.com/NativeScript/nativescript-dev-appium/issues/265)) ([5844f86](https://github.com/NativeScript/nativescript-dev-appium/commit/5844f86)) + + +### Features + +* add the ability to run adb shell commands ([#251](https://github.com/NativeScript/nativescript-dev-appium/issues/251)) ([7246bce](https://github.com/NativeScript/nativescript-dev-appium/commit/7246bce)) + + # [6.0.0](https://github.com/NativeScript/nativescript-dev-appium/compare/5.3.0...6.0.0) (2019-08-08) diff --git a/lib/appium-driver.d.ts b/lib/appium-driver.d.ts index 3563261..c32d76a 100644 --- a/lib/appium-driver.d.ts +++ b/lib/appium-driver.d.ts @@ -1,4 +1,3 @@ -export declare const should: any; import { ElementHelper } from "./element-helper"; import { SearchOptions } from "./search-options"; import { UIElement } from "./ui-element"; @@ -11,6 +10,7 @@ import { ImageHelper } from "./image-helper"; import { ImageOptions } from "./image-options"; import { LogType } from "./log-types"; import { DeviceOrientation } from "./enums/device-orientation"; +import { AndroidKeyEvent } from "mobile-devices-controller"; export declare class AppiumDriver { private _driver; private _wd; @@ -37,7 +37,7 @@ export declare class AppiumDriver { readonly isIOS: boolean; readonly driver: any; /** - * Get the storage where test results from image comparison is logged It will be reports/app nam/device name + * Get the storage where test results from image comparison is logged. The path should be reports/app nam/device name */ readonly reportsPath: string; /** @@ -265,8 +265,27 @@ export declare class AppiumDriver { */ getScreenActualViewPort(): IRectangle; /** - * Get screen view port - * This is convenient to use for some gestures on the screen + * Get screen view port. + * Provides the view port that is needed for some gestures like swipe etc. */ getScreenViewPort(): IRectangle; + /** + * Android ONLY! Input key event via ADB. + * Must be combined with '--relaxed-security' appium flag. When not running in sauceLabs '--ignoreDeviceController' should be added too. + * @param keyEvent The event number + */ + adbKeyEvent(keyEvent: number | AndroidKeyEvent): Promise; + /** + * Android ONLY! Send text via ADB. + * Must be combined with '--relaxed-security' appium flag. When not running in sauceLabs '--ignoreDeviceController' should be added too. + * @param text The string to send + */ + adbSendText(text: string): Promise; + /** + * Android ONLY! Execute shell command via ADB. + * Must be combined with '--relaxed-security' appium flag. When not running in sauceLabs '--ignoreDeviceController' should be added too. + * @param command The command name + * @param args Additional arguments + */ + adbShellCommand(command: string, args: Array): Promise; } diff --git a/lib/appium-driver.ts b/lib/appium-driver.ts index fabc4a2..f4615cc 100644 --- a/lib/appium-driver.ts +++ b/lib/appium-driver.ts @@ -1,10 +1,5 @@ import * as wd from "wd"; import * as webdriverio from "webdriverio"; -const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); -chai.use(chaiAsPromised); -export const should = chai.should(); -chaiAsPromised.transferPromiseness = wd.transferPromiseness; import { ElementHelper } from "./element-helper"; import { SearchOptions } from "./search-options"; @@ -16,7 +11,8 @@ import { DeviceController, IDevice, DeviceType, - AndroidController + AndroidController, + IOSController } from "mobile-devices-controller"; import { addExt, @@ -33,7 +29,9 @@ import { getStorage, encodeImageToBase64, ensureReportsDirExists, - checkImageLogType + checkImageLogType, + adbShellCommand, + logWarn } from "./utils"; import { INsCapabilities } from "./interfaces/ns-capabilities"; @@ -50,6 +48,8 @@ import { LogImageType } from "./enums/log-image-type"; import { DeviceOrientation } from "./enums/device-orientation"; import { NsCapabilities } from "./ns-capabilities"; import { AutomationName } from "./automation-name"; +import { AndroidKeyEvent } from "mobile-devices-controller"; +import { setInterval } from "timers"; export class AppiumDriver { private _defaultWaitTime: number = 5000; @@ -120,7 +120,7 @@ export class AppiumDriver { } /** - * Get the storage where test results from image comparison is logged It will be reports/app nam/device name + * Get the storage where test results from image comparison is logged. The path should be reports/app nam/device name */ get reportsPath() { return this._args.reportsPath; @@ -168,6 +168,11 @@ export class AppiumDriver { } public async navBack() { + if (this.isAndroid) { + logInfo("=== Navigate back with hardware button!"); + } else { + logInfo("=== Navigate back."); + } return await this._driver.back(); } @@ -218,10 +223,11 @@ export class AppiumDriver { let hasStarted = false; let retries = 10; + let sessionInfoDetails; + while (retries > 0 && !hasStarted) { try { let sessionInfo; - let sessionInfoDetails; try { if (args.sessionId || args.attachToDebug) { const sessionInfos = JSON.parse(((await getSessions(args.port)) || "{}") + ''); @@ -244,7 +250,7 @@ export class AppiumDriver { prepareApp(args); if (!args.device) { if (args.isAndroid) { - args.device = DeviceManager.getDefaultDevice(args, sessionInfo.capabilities.desired.deviceName, sessionInfo.capabilities.deviceUDID.replace("emulator-", ""), sessionInfo.capabilities.deviceUDID.includes("emulator") ? DeviceType.EMULATOR : DeviceType.SIMULATOR, sessionInfo.capabilities.desired.platformVersion || sessionInfo.capabilities.platformVersion); + args.device = DeviceManager.getDefaultDevice(args, sessionInfo.capabilities.desired.deviceName, sessionInfo.capabilities.deviceUDID.replace("emulator-", ""), sessionInfo.capabilities.deviceUDID.includes("emulator") ? DeviceType.EMULATOR : DeviceType.SIMULATOR, sessionInfo.capabilities.deviceApiLevel || sessionInfo.capabilities.platformVersion); } else { args.device = DeviceManager.getDefaultDevice(args); } @@ -257,6 +263,8 @@ export class AppiumDriver { } } catch (error) { args.verbose = true; + console.log("==============================="); + console.log("", error) if (!args.ignoreDeviceController && error && error.message && error.message.includes("Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE]")) { await DeviceManager.kill(args.device); await DeviceController.startDevice(args.device); @@ -272,7 +280,7 @@ export class AppiumDriver { logInfo("Current version of appium doesn't support appium settings!"); } - await DeviceManager.applyDeviceAdditionsSettings(driver, args, appiumCapsFromConfig); + await DeviceManager.applyDeviceAdditionsSettings(driver, args, sessionInfoDetails); hasStarted = true; } catch (error) { @@ -280,11 +288,11 @@ export class AppiumDriver { console.log("Retry launching appium driver!"); hasStarted = false; - if (error && error.message && error.message.includes("WebDriverAgent")) { - const freePort = await findFreePort(100, args.wdaLocalPort); - console.log("args.appiumCaps['wdaLocalPort']", freePort); - args.appiumCaps["wdaLocalPort"] = freePort; - } + // if (error && error.message && error.message.includes("WebDriverAgent")) { + // const freePort = await findFreePort(100, args.wdaLocalPort); + // console.log("args.appiumCaps['wdaLocalPort']", freePort); + // args.appiumCaps["wdaLocalPort"] = freePort; + // } } if (hasStarted) { @@ -314,7 +322,28 @@ export class AppiumDriver { await driver.updateSettings(appiumCapsFromConfig.settings); } - return new AppiumDriver(driver, wd, webio, args.driverConfig, args); + if (+sessionInfoDetails.statBarHeight === 0 + && sessionInfoDetails.platformName.toLowerCase() === "ios" + && sessionInfoDetails.platformVersion.startsWith("13")) { + try { + const devicesInfos = IOSController.devicesDisplaysInfos() + .filter(d => sessionInfoDetails.deviceName.includes(d.deviceType)); + + if (devicesInfos.length > 0) { + // sort devices by best match - in case we have iPhone XR 13 -> it will match device type 'iPhone X' and 'iPhone XR' -> after sort we will pick first longest match + devicesInfos + .sort((a, b) => { + return sessionInfoDetails.deviceName.replace(a.deviceType, "").length - sessionInfoDetails.deviceName.replace(b.deviceType, "").length + }); + const deviceType = devicesInfos[0]; + args.device.viewportRect.y += deviceType.actionBarHeight; + args.device.viewportRect.height -= deviceType.actionBarHeight; + } + } catch (error) { } + } + + const appiumDriver = new AppiumDriver(driver, wd, webio, args.driverConfig, args); + return appiumDriver; } public async updateSettings(settings: any) { @@ -470,7 +499,7 @@ export class AppiumDriver { * @param xOffset */ public async scroll(direction: Direction, y: number, x: number, yOffset: number, xOffset: number = 0) { - await scroll(this._wd, this._driver, direction, this._webio.isIOS, y, x, yOffset, xOffset, this._args.verbose); + await scroll(this._wd, this._driver, direction, this._webio.isIOS, y, x, yOffset, xOffset); } /** @@ -488,13 +517,15 @@ export class AppiumDriver { while ((el === null || !isDisplayed) && retryCount > 0) { try { el = await element(); - isDisplayed = await el.isDisplayed(); + isDisplayed = el && await el.isDisplayed(); if (!isDisplayed) { - await scroll(this._wd, this._driver, direction, this._webio.isIOS, startPoint.y, startPoint.x, offsetPoint.x, offsetPoint.y, this._args.verbose); + await scroll(this._wd, this._driver, direction, this._webio.isIOS, startPoint.y, startPoint.x, offsetPoint.y, offsetPoint.x); el = null; } } catch (error) { console.log("scrollTo Error: " + error); + await scroll(this._wd, this._driver, direction, this._webio.isIOS, startPoint.y, startPoint.x, offsetPoint.y, offsetPoint.x); + el = null; } retryCount--; @@ -586,11 +617,11 @@ export class AppiumDriver { return await this.driver.getSessionId(); } - public async compareElement(element: UIElement, imageName?: string, tolerance: number = 0, timeOutSeconds: number = 3, toleranceType: ImageOptions = ImageOptions.percent) { + public async compareElement(element: UIElement, imageName?: string, tolerance: number = this.imageHelper.defaultTolerance, timeOutSeconds: number = 3, toleranceType: ImageOptions = this.imageHelper.defaultToleranceType) { return await this.compareRectangle(await element.getActualRectangle(), imageName, timeOutSeconds, tolerance, toleranceType); } - public async compareRectangle(rect: IRectangle, imageName?: string, timeOutSeconds: number = 3, tolerance: number = 0, toleranceType: ImageOptions = ImageOptions.percent) { + public async compareRectangle(rect: IRectangle, imageName?: string, timeOutSeconds: number = 3, tolerance: number = this.imageHelper.defaultTolerance, toleranceType: ImageOptions = this.imageHelper.defaultToleranceType) { imageName = imageName || this.imageHelper.testName; const options = this.imageHelper.extendOptions({ imageName: imageName, @@ -604,7 +635,7 @@ export class AppiumDriver { return await this.imageHelper.compare(options); } - public async compareScreen(imageName?: string, timeOutSeconds: number = 3, tolerance: number = 0, toleranceType: ImageOptions = ImageOptions.percent) { + public async compareScreen(imageName?: string, timeOutSeconds: number = 3, tolerance: number = this.imageHelper.defaultTolerance, toleranceType: ImageOptions = this.imageHelper.defaultToleranceType) { imageName = imageName || this.imageHelper.testName; const options = this.imageHelper.extendOptions({ imageName: imageName, @@ -843,7 +874,7 @@ export class AppiumDriver { } } - private static async applyAdditionalSettings(args) { + private static async applyAdditionalSettings(args: INsCapabilities) { if (args.isSauceLab) return; args.appiumCaps['udid'] = args.appiumCaps['udid'] || args.device.token; @@ -860,6 +891,11 @@ export class AppiumDriver { args.appiumCaps["wdaStartupRetries"] = 5; args.appiumCaps["shouldUseSingletonTestManager"] = args.appiumCaps.shouldUseSingletonTestManager; + if (args.derivedDataPath) { + args.appiumCaps["derivedDataPath"] = `${args.derivedDataPath}/${args.device.token}`; + logWarn('Changed derivedDataPath to: ', args.appiumCaps["derivedDataPath"]); + } + // It looks we need it for XCTest (iOS 10+ automation) if (args.appiumCaps.platformVersion >= 10 && args.wdaLocalPort) { console.log(`args.appiumCaps['wdaLocalPort']: ${args.wdaLocalPort}`); @@ -1004,22 +1040,55 @@ export class AppiumDriver { * Useful for image comparison */ public getScreenActualViewPort(): IRectangle { - return (this._args.appiumCaps && this._args.appiumCaps.viewportRect) || this._args.device.viewportRect; + return (this._args.device.viewportRect || this.imageHelper.options.cropRectangle); } /** - * Get screen view port - * This is convenient to use for some gestures on the screen + * Get screen view port. + * Provides the view port that is needed for some gestures like swipe etc. */ public getScreenViewPort(): IRectangle { - const rect = (this._args.appiumCaps && this._args.appiumCaps.viewportRect) || this._args.device.viewportRect; - if (rect && Object.getOwnPropertyNames(rect).length > 0) { + const rect = this.getScreenActualViewPort(); + if (rect + && this.isIOS + && Object.getOwnPropertyNames(rect).length > 0 + && this._args.device.deviceScreenDensity) { return { - x: rect.x / this._args.appiumCaps.device.deviceScreenDensity, - y: rect.y / this._args.appiumCaps.device.deviceScreenDensity, - width: rect.x / this._args.appiumCaps.device.deviceScreenDensity, - height: rect.x / this._args.appiumCaps.device.deviceScreenDensity, + x: rect.x / this._args.device.deviceScreenDensity, + y: rect.y / this._args.device.deviceScreenDensity, + width: rect.width / this._args.device.deviceScreenDensity, + height: rect.height / this._args.device.deviceScreenDensity, } + } else { + return rect; } } + + /** + * Android ONLY! Input key event via ADB. + * Must be combined with '--relaxed-security' appium flag. When not running in sauceLabs '--ignoreDeviceController' should be added too. + * @param keyEvent The event number + */ + public async adbKeyEvent(keyEvent: number | AndroidKeyEvent) { + await this.adbShellCommand("input", ["keyevent", keyEvent]); + } + + /** + * Android ONLY! Send text via ADB. + * Must be combined with '--relaxed-security' appium flag. When not running in sauceLabs '--ignoreDeviceController' should be added too. + * @param text The string to send + */ + public async adbSendText(text: string) { + await this.adbShellCommand("input", ["text", text]); + } + + /** + * Android ONLY! Execute shell command via ADB. + * Must be combined with '--relaxed-security' appium flag. When not running in sauceLabs '--ignoreDeviceController' should be added too. + * @param command The command name + * @param args Additional arguments + */ + public async adbShellCommand(command: string, args: Array) { + await adbShellCommand(this._driver, command, args); + } } \ No newline at end of file diff --git a/lib/appium-server.ts b/lib/appium-server.ts index e22ef5e..85230b4 100644 --- a/lib/appium-server.ts +++ b/lib/appium-server.ts @@ -126,7 +126,7 @@ export class AppiumServer { private startAppiumServer(logLevel: string, isSauceLab: boolean) { const startingServerArgs: Array = isSauceLab ? ["--log-level", logLevel] : ["-p", this.port.toString(), "--log-level", logLevel]; - if (this._args.isAndroid && this._args.ignoreDeviceController && !this._args.isSauceLab) { + if (this._args.isAndroid) { this._args.relaxedSecurity ? startingServerArgs.push("--relaxed-security") : console.log("'relaxedSecurity' is not enabled!\nTo enabled it use '--relaxedSecurity'!"); } diff --git a/lib/device-manager.d.ts b/lib/device-manager.d.ts index e535392..c6c8916 100644 --- a/lib/device-manager.d.ts +++ b/lib/device-manager.d.ts @@ -13,13 +13,18 @@ export declare class DeviceManager implements IDeviceManager { static getInstalledApps(device: IDevice): Promise; static getDefaultDevice(args: INsCapabilities, deviceName?: string, token?: string, type?: DeviceType, platformVersion?: number): IDevice; private static convertViewportRectToIRectangle; - static applyAppiumSessionInfoDetails(args: INsCapabilities, sessionInfoDetails: any): IDevice; + static applyAppiumSessionInfoDetails(args: INsCapabilities, sessionInfoDetails: any): any; static setDontKeepActivities(args: INsCapabilities, driver: any, value: any): Promise; static executeShellCommand(driver: any, commandArgs: { command: string; "args": Array; }): Promise; - static getDensity(args: INsCapabilities, driver: any): Promise; + /** + * Android only + * @param args + * @param driver + */ + static setDensity(args: INsCapabilities, driver: any): Promise; static applyDeviceAdditionsSettings(driver: any, args: INsCapabilities, sessionInfo: any): Promise; getPackageId(device: IDevice, appPath: string): string; private static cleanUnsetProperties; diff --git a/lib/device-manager.ts b/lib/device-manager.ts index 604e67a..385f0ab 100644 --- a/lib/device-manager.ts +++ b/lib/device-manager.ts @@ -171,12 +171,10 @@ export class DeviceManager implements IDeviceManager { type: type, platform: args.appiumCaps.platformName.toLowerCase(), token: token, - apiLevel: platformVersion || args.appiumCaps.platformVersion, + apiLevel: platformVersion || args.appiumCaps.deviceApiLevel || args.appiumCaps.platformVersion, config: { "density": args.appiumCaps.density, "offsetPixels": args.appiumCaps.offsetPixels } } - delete args.appiumCaps.density; - delete args.appiumCaps.offsetPixels; DeviceManager.cleanUnsetProperties(device); return device; @@ -199,18 +197,19 @@ export class DeviceManager implements IDeviceManager { const sizeArr = sessionInfoDetails.deviceScreenSize.split("x"); args.device.deviceScreenSize = { width: sizeArr[0], height: sizeArr[1] }; - args.device.apiLevel = sessionInfoDetails.deviceApiLevel; + args.device.apiLevel = sessionInfoDetails.deviceApiLevel || args.device.apiLevel; args.device.deviceScreenDensity = sessionInfoDetails.deviceScreenDensity / 100; args.device.config = { "density": args.device.deviceScreenDensity || args.device.config.density, "offsetPixels": +sessionInfoDetails.statBarHeight || args.device.config.offsetPixels }; } else { args.device.apiLevel = sessionInfoDetails.platformVersion; - args.device.deviceScreenDensity = sessionInfoDetails.pixelRatio; + args.device.deviceScreenDensity = sessionInfoDetails.pixelRatio || args.device.config.density; const offsetPixels = +sessionInfoDetails.viewportRect.top - +sessionInfoDetails.statBarHeight; args.device.config = { "density": sessionInfoDetails.pixelRatio || args.device.config.density, "offsetPixels": isNumber(offsetPixels) ? offsetPixels : args.device.config.offsetPixels }; } args.device.statBarHeight = sessionInfoDetails.statBarHeight; args.device.viewportRect = DeviceManager.convertViewportRectToIRectangle(sessionInfoDetails.viewportRect); + args.device.token = args.device.token || sessionInfoDetails.udid; return args.device; } @@ -219,14 +218,14 @@ export class DeviceManager implements IDeviceManager { const status = value ? 1 : 0; try { if (args.isAndroid) { - if (!args.ignoreDeviceController) { - AndroidController.setDontKeepActivities(value, args.device); - } else if (args.relaxedSecurity) { + if (args.relaxedSecurity) { const output = await DeviceManager.executeShellCommand(driver, { command: "settings", args: ['put', 'global', 'always_finish_activities', status] }); console.log(`Output from setting always_finish_activities to ${status}: ${output}`); //check if set const check = await DeviceManager.executeShellCommand(driver, { command: "settings", args: ['get', 'global', 'always_finish_activities'] }); console.info(`Check if always_finish_activities is set correctly: ${check}`); + } else if (!args.ignoreDeviceController) { + AndroidController.setDontKeepActivities(value, args.device); } } else { // Do nothing for iOS ... @@ -242,7 +241,12 @@ export class DeviceManager implements IDeviceManager { return output; } - public static async getDensity(args: INsCapabilities, driver) { + /** + * Android only + * @param args + * @param driver + */ + public static async setDensity(args: INsCapabilities, driver) { args.device.config = args.device.config || {}; if (args.appiumCaps.platformName.toLowerCase() === "android") { if (!args.ignoreDeviceController) { @@ -258,15 +262,6 @@ export class DeviceManager implements IDeviceManager { if (args.device.config.density) { args.device.config['offsetPixels'] = AndroidController.calculateScreenOffset(args.device.config.density); } - } else { - IOSController.getDevicesScreenInfo().forEach((v, k, m) => { - if (args.device.name.includes(k)) { - args.device.config = { - density: args.device.config['density'] || v.density, - offsetPixels: v.actionBarHeight - }; - } - }); } } @@ -278,11 +273,11 @@ export class DeviceManager implements IDeviceManager { // } public static async applyDeviceAdditionsSettings(driver, args: INsCapabilities, sessionInfo: any) { - if ((!args.device.viewportRect || !args.device.viewportRect.x) && (!args.device.config || !args.device.config.offsetPixels)) { + if ((!args.device.viewportRect || !args.device.viewportRect.x) && (!args.device.config || !isNumber(args.device.config.offsetPixels))) { args.device.config = {}; let density: number; - if (sessionInfo && sessionInfo.length >= 1) { - density = sessionInfo[1].deviceScreenDensity ? sessionInfo[1].deviceScreenDensity / 100 : undefined; + if (sessionInfo && Object.getOwnPropertyNames(sessionInfo).length >= 1) { + density = sessionInfo.pixelRatio ? sessionInfo.pixelRatio : undefined; } if (density) { @@ -291,8 +286,8 @@ export class DeviceManager implements IDeviceManager { args.device.config['offsetPixels'] = AndroidController.calculateScreenOffset(args.device.config.density); } - if (!density) { - await DeviceManager.getDensity(args, driver); + if (!density && !args.isIOS) { + await DeviceManager.setDensity(args, driver); density = args.device.config.density args.device.config['offsetPixels'] = AndroidController.calculateScreenOffset(args.device.config.density); } diff --git a/lib/direction.d.ts b/lib/direction.d.ts index 8fe9856..2a7257e 100644 --- a/lib/direction.d.ts +++ b/lib/direction.d.ts @@ -1,4 +1,4 @@ -export declare const enum Direction { +export declare enum Direction { down = 0, up = 1, left = 2, diff --git a/lib/direction.ts b/lib/direction.ts index d1a3683..0fd5bd8 100644 --- a/lib/direction.ts +++ b/lib/direction.ts @@ -1,4 +1,4 @@ -export const enum Direction { +export enum Direction { down, up, left, diff --git a/lib/image-helper.d.ts b/lib/image-helper.d.ts index f499009..3842db1 100644 --- a/lib/image-helper.d.ts +++ b/lib/image-helper.d.ts @@ -17,8 +17,8 @@ export interface IImageCompareOptions { */ toleranceType?: ImageOptions; /** - * Wait miliseconds before capture creating image - * Default value is 2000 + * Wait milliseconds before capture creating image + * Default value is 5000 */ waitBeforeCreatingInitialImageCapture?: number; /** @@ -43,14 +43,14 @@ export interface IImageCompareOptions { keepOriginalImageSize?: boolean; /** * Default value is set to false. nativescript-dev-appium will recalculate view port for iOS - * so that the top/y will start from the end of status bar + * so that the top/y will start from the end of the status bar * So far appium calculates it even more and some part of safe areas are missed */ keepAppiumViewportRect?: boolean; /** - * Defines if an image is device specific or only by platform. - * Default value is true and the image will be saved in device specific directory. - * If value is set to false, image will be saved under ios or android folder. + * Defines if an image is device-specific or only by the platform. + * Default value is true and the image will be saved in device-specific directory. + * If the value is set to false, the image will be saved under ios or android folder. */ isDeviceSpecific?: boolean; /** @@ -64,6 +64,8 @@ export declare class ImageHelper { private _blockOutAreas; private _imagesResults; private _options; + private _defaultToleranceType; + private _defaultTolerance; private _defaultOptions; constructor(_args: INsCapabilities, _driver: AppiumDriver); static readonly pngFileExt = ".png"; @@ -80,6 +82,8 @@ export declare class ImageHelper { delta: number; options: IImageCompareOptions; blockOutAreas: IRectangle[]; + defaultToleranceType: ImageOptions; + defaultTolerance: number; compareScreen(options?: IImageCompareOptions): Promise; compareElement(element: UIElement, options?: IImageCompareOptions): Promise; compareRectangle(cropRectangle: IRectangle, options?: IImageCompareOptions): Promise; diff --git a/lib/image-helper.ts b/lib/image-helper.ts index 7ead1ae..139024d 100644 --- a/lib/image-helper.ts +++ b/lib/image-helper.ts @@ -29,8 +29,8 @@ export interface IImageCompareOptions { toleranceType?: ImageOptions; /** - * Wait miliseconds before capture creating image - * Default value is 2000 + * Wait milliseconds before capture creating image + * Default value is 5000 */ waitBeforeCreatingInitialImageCapture?: number; @@ -53,7 +53,7 @@ export interface IImageCompareOptions { /** * Default value is set to true which means that nativescript-dev-appium will save the image - * in original size and compare only the part which cropRectangle specifies. + * in original size and compare only the part which cropRectangle specifies. * If false, the image size will be reduced and saved by the dimensions of cropRectangle. */ keepOriginalImageSize?: boolean; @@ -61,15 +61,15 @@ export interface IImageCompareOptions { /** * Default value is set to false. nativescript-dev-appium will recalculate view port for iOS - * so that the top/y will start from the end of status bar + * so that the top/y will start from the end of the status bar * So far appium calculates it even more and some part of safe areas are missed */ keepAppiumViewportRect?: boolean; /** - * Defines if an image is device specific or only by platform. - * Default value is true and the image will be saved in device specific directory. - * If value is set to false, image will be saved under ios or android folder. + * Defines if an image is device-specific or only by the platform. + * Default value is true and the image will be saved in device-specific directory. + * If the value is set to false, the image will be saved under ios or android folder. */ isDeviceSpecific?: boolean; @@ -83,6 +83,8 @@ export class ImageHelper { private _blockOutAreas: IRectangle[]; private _imagesResults = new Map(); private _options: IImageCompareOptions = {}; + private _defaultToleranceType: ImageOptions = ImageOptions.percent; + private _defaultTolerance: number = 0; private _defaultOptions: IImageCompareOptions = { timeOutSeconds: 2, tolerance: 0, @@ -98,11 +100,13 @@ export class ImageHelper { }; constructor(private _args: INsCapabilities, private _driver: AppiumDriver) { - this._defaultOptions.cropRectangle = (this._args.appiumCaps && this._args.appiumCaps.viewportRect) || this._args.device.viewportRect; + if (this._args.device.viewportRect) { + ImageHelper.fullClone(this._args.device.viewportRect, this._defaultOptions.cropRectangle) + } if (!this._defaultOptions.cropRectangle - || !isNumber(this._defaultOptions.cropRectangle.y)) { + || !isNumber(this._defaultOptions.cropRectangle.y) || this._args.appiumCaps.offsetPixels > 0) { this._defaultOptions.cropRectangle = this._defaultOptions.cropRectangle || {}; - this._defaultOptions.cropRectangle.y = this._args.device.config.offsetPixels || 0; + this._defaultOptions.cropRectangle.y = this._args.appiumCaps.offsetPixels || this._args.device.config.offsetPixels || 0; this._defaultOptions.cropRectangle.x = 0; if (this._args.device.deviceScreenSize && this._args.device.deviceScreenSize.width && this._args.device.deviceScreenSize.height) { this._defaultOptions.cropRectangle.height = this._args.device.deviceScreenSize.height - this._defaultOptions.cropRectangle.y; @@ -146,6 +150,22 @@ export class ImageHelper { this._blockOutAreas = rectangles; } + get defaultToleranceType(): ImageOptions { + return this._defaultToleranceType; + } + + set defaultToleranceType(toleranceType: ImageOptions) { + this._defaultToleranceType = toleranceType; + } + + get defaultTolerance(): number { + return this._defaultTolerance; + } + + set defaultTolerance(tolerance: number) { + this._defaultTolerance = tolerance; + } + public async compareScreen(options?: IImageCompareOptions) { options = this.extendOptions(options); options.imageName = this.increaseImageName(options.imageName || this.testName, options); @@ -268,8 +288,12 @@ export class ImageHelper { const eventStartTime = Date.now().valueOf(); let counter = 1; options.timeOutSeconds *= 1000; + let pathActualImageCounter = resolvePath(this._args.reportsPath, imageName.replace(".", "_actual.")); + const shouldLogEveryImage = checkImageLogType(this._args.testReporter, LogImageType.everyImage); while ((Date.now().valueOf() - eventStartTime) <= options.timeOutSeconds && !result) { - const pathActualImageCounter = resolvePath(this._args.reportsPath, imageName.replace(".", "_actual_" + counter + ".")); + if (shouldLogEveryImage) { + pathActualImageCounter = resolvePath(this._args.reportsPath, imageName.replace(".", "_actual_" + counter + ".")); + } pathActualImage = await this._driver.saveScreenshot(pathActualImageCounter); if (!options.keepOriginalImageSize) { await this.clipRectangleImage(options.cropRectangle, pathActualImage); @@ -308,13 +332,13 @@ export class ImageHelper { public compareImages(options: IImageCompareOptions, actual: string, expected: string, output: string) { const clipRect = { - x: this.options.cropRectangle.x, - y: this.options.cropRectangle.y, - width: this.options.cropRectangle.width, - height: this.options.cropRectangle.height + x: options.cropRectangle.x, + y: options.cropRectangle.y, + width: options.cropRectangle.width, + height: options.cropRectangle.height } - if (!this.options.keepOriginalImageSize) { + if (!options.keepOriginalImageSize) { clipRect.x = 0; clipRect.y = 0; clipRect.width = undefined; diff --git a/lib/interfaces/ns-capabilities-args.d.ts b/lib/interfaces/ns-capabilities-args.d.ts index f1b29c9..a0c9a71 100644 --- a/lib/interfaces/ns-capabilities-args.d.ts +++ b/lib/interfaces/ns-capabilities-args.d.ts @@ -4,6 +4,7 @@ import { AutomationName } from "../automation-name"; import { ITestReporter } from "./test-reporter"; import { LogImageType } from "../enums/log-image-type"; export interface INsCapabilitiesArgs { + derivedDataPath?: string; port?: number; wdaLocalPort?: number; projectDir?: string; diff --git a/lib/interfaces/ns-capabilities-args.ts b/lib/interfaces/ns-capabilities-args.ts index 5e0ea58..2375303 100644 --- a/lib/interfaces/ns-capabilities-args.ts +++ b/lib/interfaces/ns-capabilities-args.ts @@ -5,6 +5,7 @@ import { ITestReporter } from "./test-reporter"; import { LogImageType } from "../enums/log-image-type"; export interface INsCapabilitiesArgs { + derivedDataPath?: string; port?: number; wdaLocalPort?: number; projectDir?: string; diff --git a/lib/ns-capabilities.d.ts b/lib/ns-capabilities.d.ts index ae4f03a..267ed98 100644 --- a/lib/ns-capabilities.d.ts +++ b/lib/ns-capabilities.d.ts @@ -47,6 +47,7 @@ export declare class NsCapabilities implements INsCapabilities { deviceTypeOrPlatform: string; driverConfig: any; logImageTypes: Array; + derivedDataPath: string; constructor(_parser: INsCapabilitiesArgs); readonly isAndroid: any; readonly isIOS: boolean; diff --git a/lib/ns-capabilities.ts b/lib/ns-capabilities.ts index cb99c78..b9a123a 100644 --- a/lib/ns-capabilities.ts +++ b/lib/ns-capabilities.ts @@ -53,6 +53,7 @@ export class NsCapabilities implements INsCapabilities { public deviceTypeOrPlatform: string; public driverConfig: any; public logImageTypes: Array; + public derivedDataPath: string; constructor(private _parser: INsCapabilitiesArgs) { this.projectDir = this._parser.projectDir; @@ -76,6 +77,7 @@ export class NsCapabilities implements INsCapabilities { this.isSauceLab = this._parser.isSauceLab; this.ignoreDeviceController = this._parser.ignoreDeviceController; this.wdaLocalPort = this._parser.wdaLocalPort; + this.derivedDataPath = this._parser.derivedDataPath; this.path = this._parser.path; this.capabilitiesName = this._parser.capabilitiesName; this.imagesPath = this._parser.imagesPath; diff --git a/lib/parser.d.ts b/lib/parser.d.ts index 386ae5d..b9aae80 100644 --- a/lib/parser.d.ts +++ b/lib/parser.d.ts @@ -1,2 +1,2 @@ import { LogImageType } from "./enums/log-image-type"; -export declare const projectDir: string, projectBinary: string, pluginRoot: string, pluginBinary: string, port: number, verbose: boolean, appiumCapsLocation: string, testFolder: string, runType: string, isSauceLab: boolean, appPath: string, storage: string, testReports: string, devMode: boolean, ignoreDeviceController: boolean, wdaLocalPort: number, path: string, relaxedSecurity: boolean, cleanApp: boolean, attachToDebug: boolean, sessionId: string, startSession: boolean, capabilitiesName: string, imagesPath: string, startDeviceOptions: string, deviceTypeOrPlatform: string, device: import("mobile-devices-controller/lib/device").IDevice, driverConfig: any, logImageTypes: LogImageType[], appiumCaps: any; +export declare const projectDir: string, projectBinary: string, pluginRoot: string, pluginBinary: string, port: number, verbose: boolean, appiumCapsLocation: string, testFolder: string, runType: string, isSauceLab: boolean, appPath: string, storage: string, testReports: string, devMode: boolean, ignoreDeviceController: boolean, wdaLocalPort: number, derivedDataPath: string, path: string, relaxedSecurity: boolean, cleanApp: boolean, attachToDebug: boolean, sessionId: string, startSession: boolean, capabilitiesName: string, imagesPath: string, startDeviceOptions: string, deviceTypeOrPlatform: string, device: any, driverConfig: any, logImageTypes: LogImageType[], appiumCaps: any; diff --git a/lib/parser.ts b/lib/parser.ts index 977e173..dfd21e9 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -64,6 +64,9 @@ const config = (() => { type: "string" }) .option("wdaLocalPort", { alias: "wda", describe: "WDA port", type: "number" }) + .options("derivedDataPath", { + describe: "set the unique derived data path root for each driver instance. This will help to avoid possible conflicts and to speed up the parallel execution", + type: "string" }) .option("verbose", { alias: "v", describe: "Log actions", type: "boolean" }) .option("path", { describe: "Execution path", default: process.cwd(), type: "string" }) .option("relaxedSecurity", { describe: "appium relaxedSecurity", default: false, type: "boolean" }) @@ -160,6 +163,7 @@ const config = (() => { pluginRoot: pluginRoot, pluginBinary: pluginBinary, wdaLocalPort: options.wdaLocalPort || process.env.npm_config_wdaLocalPort || process.env["WDA_LOCAL_PORT"] || 8410, + derivedDataPath: options.derivedDataPath || process.env.npm_config_derivedDataPath || process.env["DERIVED_DATA_PATH"], testFolder: options.testFolder || process.env.npm_config_testFolder || "e2e", runType: options.runType || process.env.npm_config_runType, appiumCapsLocation: options.appiumCapsLocation || process.env.npm_config_appiumCapsLocation || join(projectDir, options.testFolder, "config", options.capabilitiesName), @@ -208,6 +212,7 @@ export const { devMode, ignoreDeviceController, wdaLocalPort, + derivedDataPath, path, relaxedSecurity, cleanApp, diff --git a/lib/ui-element.d.ts b/lib/ui-element.d.ts index 167d580..ee66fbb 100644 --- a/lib/ui-element.d.ts +++ b/lib/ui-element.d.ts @@ -16,6 +16,10 @@ export declare class UIElement { * Click on element */ click(): Promise; + getCenter(): Promise<{ + x: number; + y: number; + }>; tapCenter(): Promise; tapAtTheEnd(): Promise; /** @@ -26,9 +30,14 @@ export declare class UIElement { */ tap(): Promise; /** + * @experimental * Double tap on element */ - doubleTap(): Promise; + doubleTap(offset?: { + x: number; + y: number; + }): Promise; + longPress(duration: number): Promise; /** * Get location of element */ @@ -36,7 +45,10 @@ export declare class UIElement { /** * Get size of element */ - size(): Promise; + size(): Promise<{ + width: number; + height: number; + }>; /** * Get text of element */ @@ -113,23 +125,19 @@ export declare class UIElement { */ scrollTo(direction: Direction, elementToSearch: () => Promise, yOffset?: number, xOffset?: number, retries?: number): Promise; /** - * Drag element with specific offset - * @param direction - * @param yOffset - * @param xOffset - default value 0 - */ - drag(direction: Direction, yOffset: number, xOffset?: number): Promise; - /** * Click and hold over an element * @param time in milliseconds to increase the default press period. */ hold(time?: number): Promise; /** * Send keys to field or other UI component - * @param text - * @param shouldClearText, default value is true + * @param text The string to input + * @param shouldClearText Clears existing input before send new one - default value is 'true' + * @param useAdb default value is false. Usable for Android ONLY ! + * Must be combined with '--relaxed-security' appium flag. When not running in sauceLabs '--ignoreDeviceController' should be added too. + * @param adbDeleteCharsCount default value is 10. Usable for Android ONLY when 'useAdb' and 'shouldClearText' are True! */ - sendKeys(text: string, shouldClearText?: boolean): Promise; + sendKeys(text: string, shouldClearText?: boolean, useAdb?: boolean, adbDeleteCharsCount?: number): Promise; /** * Type text to field or other UI component * @param text @@ -142,9 +150,15 @@ export declare class UIElement { */ pressKeycode(keyCode: number): Promise; /** - * Clears text form ui element + * Clears text from ui element */ clearText(): Promise; + /** + * Clears text from ui element with ADB. Android ONLY ! + * Must be combined with '--relaxed-security' appium flag. When not running in sauceLabs '--ignoreDeviceController' should be added too. + * @param charactersCount Characters count to delete. (Optional - default value 10) + */ + adbDeleteText(charactersCount?: number): Promise; log(): Promise; refetch(): Promise; /** @@ -156,4 +170,50 @@ export declare class UIElement { * @param direction */ swipe(direction: Direction): Promise; + /** + * Drag element with specific offset + * @experimental + * @param direction + * @param yOffset + * @param xOffset - default value 0 + */ + drag(direction: Direction, yOffset: number, xOffset?: number, duration?: number): Promise; + /** + *@experimental + * Pan element with specific offset + * @param offsets where the finger should move to. + * @param initPointOffset element.getRectangle() is used as start point. In case some additional offset should be provided use this param. + */ + pan(offsets: { + x: number; + y: number; + }[], initPointOffset?: { + x: number; + y: number; + }): Promise; + /** + * @experimental + * This method will try to move two fingers from opposite corners. + * One finger starts from + * { x: elementRect.x + offset.x, y: elementRect.y + offset.y } + * and ends to + * { x: elementRect.x + elementRect.width - offset.x, y: elementRect.height + elementRect.y - offset.y } + * and the other finger starts from + * { x: elementRect.width + elementRect.x - offset.x, y: elementRect.height + elementRect.y - offset.y } + * and ends to + * { x: elementRect.x + offset.x, y: elementRect.y + offset.y } + */ + rotate(offset?: { + x: number; + y: number; + }): Promise; + /** + * @experimental + * @param zoomFactory - zoomIn or zoomOut. Only zoomIn action is implemented + * @param offset + */ + pinch(zoomType: "in" | "out", offset?: { + x: number; + y: number; + }): Promise; } diff --git a/lib/ui-element.ts b/lib/ui-element.ts index 4b940e2..62a37fa 100644 --- a/lib/ui-element.ts +++ b/lib/ui-element.ts @@ -2,7 +2,7 @@ import { Point } from "./point"; import { Direction } from "./direction"; import { INsCapabilities } from "./interfaces/ns-capabilities"; import { AutomationName } from "./automation-name"; -import { calculateOffset } from "./utils"; +import { calculateOffset, adbShellCommand, logError, wait, logInfo } from "./utils"; import { AndroidKeyEvent } from "mobile-devices-controller"; export class UIElement { @@ -24,12 +24,17 @@ export class UIElement { return await (await this.element()).click(); } + public async getCenter() { + const rect = await this.getRectangle(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + } + public async tapCenter() { let action = new this._wd.TouchAction(this._driver); - const rect = await this.getActualRectangle(); - this._args.testReporterLog(`Tap on center element ${{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }}`); + const centerRect = await this.getCenter(); + this._args.testReporterLog(`Tap on center element x: ${centerRect.x} y: ${centerRect.y}`); action - .tap({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); + .tap(centerRect); await action.perform(); await this._driver.sleep(150); } @@ -49,7 +54,7 @@ export class UIElement { * This method is not working very good with UiAutomator2 * It is better to use click instead. */ - + public async tap() { if (this._args.automationName == AutomationName.UiAutomator2) { return await this.tapCenter(); @@ -59,10 +64,40 @@ export class UIElement { } /** + * @experimental * Double tap on element */ - public async doubleTap() { - return await this._driver.execute('mobile: doubleTap', { element: (await this.element()).value.ELEMENT }); + public async doubleTap(offset: { x: number, y: number } = { x: 0, y: 0 }) { + if (this._args.isAndroid) { + // hack double tap for android + const rect = await this.getRectangle(); + + if (`${this._args.device.apiLevel}`.startsWith("29") + || `${this._args.device.apiLevel}`.startsWith("9.")) { + const offsetPoint = { x: (rect.x + offset.x), y: (rect.y + offset.y) }; + await adbShellCommand(this._driver, "input", ["tap", `${offsetPoint.x} ${offsetPoint.y}`]); + await adbShellCommand(this._driver, "input", ["tap", `${offsetPoint.x} ${offsetPoint.y}`]); + } else { + let action = new this._wd.TouchAction(this._driver); + action.press({ x: rect.x, y: rect.y }).release().perform(); + action.press({ x: rect.x, y: rect.y }).release().perform(); + await action.perform(); + } + } else { + // this works only for ios, otherwise it throws error + return await this._driver.execute('mobile: doubleTap', { element: this._element.value }); + } + } + + public async longPress(duration: number) { + const rect = await this.getCenter(); + console.log("LongPress at ", rect); + const action = new this._wd.TouchAction(this._driver); + action + .press({ x: rect.x, y: rect.y }) + .wait(duration) + .release(); + await action.perform(); } /** @@ -77,10 +112,9 @@ export class UIElement { /** * Get size of element */ - public async size() { + public async size(): Promise<{ width: number, height: number }> { const size = await (await this.element()).getSize(); - const point = new Point(size.height, size.width); - return point; + return size; } /** @@ -233,7 +267,7 @@ export class UIElement { public async getRectangle() { const location = await this.location(); const size = await this.size(); - const rect = { x: location.x, y: location.y, width: size.y, height: size.x }; + const rect = { x: location.x, y: location.y, width: size.width, height: size.height }; return rect; } @@ -242,15 +276,15 @@ export class UIElement { */ public async getActualRectangle() { const actRect = await this.getRectangle(); + const density = this._args.device.deviceScreenDensity; if (this._args.isIOS) { - const density = this._args.device.config.density; if (density) { actRect.x *= density; actRect.y *= density; actRect.width *= density; actRect.height *= density; } else { - throw new Error("Device's density is undefined!"); + logError("Device's density is undefined!"); } } return actRect; @@ -263,40 +297,34 @@ export class UIElement { * @param xOffset */ public async scroll(direction: Direction, yOffset: number = 0, xOffset: number = 0) { - //await this._driver.execute("mobile: scroll", [{direction: 'up'}]) - //await this._driver.execute('mobile: scroll', { direction: direction === 0 ? "down" : "up", element: this._element.ELEMENT }); const location = await this.location(); const size = await this.size(); - const x = location.x === 0 ? 10 : location.x; - let y = (location.y + 15); - if (yOffset === 0) { - yOffset = location.y + size.y - 15; - } - - if (direction === Direction.down) { - y = (location.y + size.y) - 15; - if (!this._webio.isIOS) { - if (yOffset === 0) { - yOffset = location.y + size.y - 15; - } + if (direction === Direction.down || direction === Direction.up) { + if (xOffset > 0) { + location.x += xOffset; } - } - if (direction === Direction.up) { if (yOffset === 0) { - yOffset = size.y - 15; + yOffset = location.y + size.height - 5; } } - const endPoint = calculateOffset(direction, y, yOffset, x, xOffset, this._webio.isIOS, false); - if (direction === Direction.down) { - //endPoint.point.y += location.y; + if (direction === Direction.left || direction === Direction.right) { + if (yOffset > 0) { + location.y += yOffset; + } + if (xOffset === 0) { + xOffset = location.x + size.width - 5; + } } - let action = new this._wd.TouchAction(this._driver); + + const endPoint = calculateOffset(direction, location.y, yOffset, location.x, xOffset, this._args.isIOS); + + const action = new this._wd.TouchAction(this._driver); action - .press({ x: x, y: y }) + .press({ x: endPoint.startPoint.x, y: endPoint.startPoint.y }) .wait(endPoint.duration) - .moveTo({ x: endPoint.point.x, y: endPoint.point.y }) + .moveTo({ x: endPoint.endPoint.x, y: endPoint.endPoint.y }) .release(); await action.perform(); await this._driver.sleep(150); @@ -316,7 +344,7 @@ export class UIElement { while (el === null && retries >= 0) { try { el = await elementToSearch(); - if (!el || el === null || !(await el.isDisplayed())) { + if (!el || el === null || !(el && await el.isDisplayed())) { el = null; await this.scroll(direction, yOffset, xOffset); } @@ -329,41 +357,6 @@ export class UIElement { return el; } - /** - * Drag element with specific offset - * @param direction - * @param yOffset - * @param xOffset - default value 0 - */ - public async drag(direction: Direction, yOffset: number, xOffset: number = 0) { - const location = await this.location(); - - const x = location.x === 0 ? 10 : location.x; - const y = location.y === 0 ? 10 : location.y; - - const endPoint = calculateOffset(direction, y, yOffset, x, xOffset, this._webio.isIOS, false); - - if (this._args.isAndroid) { - let action = new this._wd.TouchAction(this._driver); - action - .longPress({ x: x, y: y }) - .wait(endPoint.duration) - .moveTo({ x: yOffset, y: yOffset }) - .release(); - await action.perform(); - } else { - await this._wd.execute(`mobile: dragFromToForDuration`, { - duration: endPoint.duration, - fromX: x, - fromY: y, - toX: xOffset, - toY: yOffset - }); - } - - await this._driver.sleep(150); - } - /** * Click and hold over an element * @param time in milliseconds to increase the default press period. @@ -380,14 +373,26 @@ export class UIElement { /** * Send keys to field or other UI component - * @param text - * @param shouldClearText, default value is true + * @param text The string to input + * @param shouldClearText Clears existing input before send new one - default value is 'true' + * @param useAdb default value is false. Usable for Android ONLY ! + * Must be combined with '--relaxed-security' appium flag. When not running in sauceLabs '--ignoreDeviceController' should be added too. + * @param adbDeleteCharsCount default value is 10. Usable for Android ONLY when 'useAdb' and 'shouldClearText' are True! */ - public async sendKeys(text: string, shouldClearText: boolean = true) { - if (shouldClearText) { - await this.clearText(); + public async sendKeys(text: string, shouldClearText: boolean = true, useAdb: boolean = false, adbDeleteCharsCount: number = 10) { + if (useAdb && this._args.isAndroid) { + if (shouldClearText) { + await this.adbDeleteText(adbDeleteCharsCount); + } + text = text.replace(" ", "%s"); + await this.click(); + await adbShellCommand(this._driver, "input", ["text", text]); + } else { + if (shouldClearText) { + await this.clearText(); + } + await this._element.sendKeys(text); } - await this._element.sendKeys(text); } /** @@ -407,17 +412,30 @@ export class UIElement { * @param key code */ public async pressKeycode(keyCode: number) { - await this._driver.pressKeycode(keyCode); + await this._driver.pressKeyCode(keyCode); } /** - * Clears text form ui element + * Clears text from ui element */ public async clearText() { await this.click(); await this._element.clear(); } + /** + * Clears text from ui element with ADB. Android ONLY ! + * Must be combined with '--relaxed-security' appium flag. When not running in sauceLabs '--ignoreDeviceController' should be added too. + * @param charactersCount Characters count to delete. (Optional - default value 10) + */ + public async adbDeleteText(charactersCount: number = 10) { + await this.click(); + for (let index = 0; index < charactersCount; index++) { + // Keyevent 67 Delete (backspace) + await adbShellCommand(this._driver, "input", ["keyevent", AndroidKeyEvent.KEYCODE_DEL]); + } + } + public async log() { const el = await this.element(); console.dir(el); @@ -449,35 +467,161 @@ export class UIElement { * @param direction */ public async swipe(direction: Direction) { - const rectangle = await this.getRectangle(); - const centerX = rectangle.x + rectangle.width / 2; - const centerY = rectangle.y + rectangle.height / 2; - let swipeX; - if (direction == Direction.right) { - const windowSize = await this._driver.getWindowSize(); - swipeX = windowSize.width - 10; - } else if (direction == Direction.left) { - swipeX = 10; + logInfo(`Swipe direction: `, Direction[direction]); + if (this._args.isIOS) { + await this._driver + .execute('mobile: scroll', { + element: this._element.value, + direction: Direction[direction] + }); } else { - console.log("Provided direction must be left or right !"); + try { + await this.scroll(direction); + } catch (error) { + console.log("", error); + } } + logInfo(`End swipe`); + } + + /** + * Drag element with specific offset + * @experimental + * @param direction + * @param yOffset + * @param xOffset - default value 0 + */ + public async drag(direction: Direction, yOffset: number, xOffset: number = 0, duration?: number) { + direction = direction === Direction.up ? Direction.down : Direction.up; + + const location = await this.location(); + + const x = location.x === 0 ? 10 : location.x; + const y = location.y === 0 ? 10 : location.y; + + const endPoint = calculateOffset(direction, y, yOffset, x, xOffset, this._args.isIOS); + duration = duration || endPoint.duration; if (this._args.isAndroid) { - const action = new this._wd.TouchAction(this._driver); - action.press({ x: centerX, y: centerY }) - .wait(200) - .moveTo({ x: swipeX, y: centerY }) + let action = new this._wd.TouchAction(this._driver); + action + .longPress({ x: x, y: y }) + .wait(duration) + .moveTo({ x: yOffset, y: yOffset }) .release(); await action.perform(); - } - else { - await this._driver.execute('mobile: dragFromToForDuration', { - duration: 2.0, - fromX: centerX, - fromY: centerY, - toX: swipeX, - toY: centerY + } else { + await this._driver.execute(`mobile: dragFromToForDuration`, { + duration: duration, + fromX: x, + fromY: y, + toX: xOffset, + toY: yOffset }); } + + await this._driver.sleep(150); + } + + /** + *@experimental + * Pan element with specific offset + * @param offsets where the finger should move to. + * @param initPointOffset element.getRectangle() is used as start point. In case some additional offset should be provided use this param. + */ + public async pan(offsets: { x: number, y: number }[], initPointOffset: { x: number, y: number } = { x: 0, y: 0 }) { + logInfo("Start pan gesture!"); + const rect = await this.getRectangle(); + const action = new this._wd.TouchAction(this._driver); + await action.press({ x: rect.x + initPointOffset.x, y: rect.y + initPointOffset.y }).wait(200); + if (offsets.length > 1) { + for (let index = 1; index < offsets.length; index++) { + const p = offsets[index]; + action.moveTo({ x: rect.x + p.x, y: rect.y + p.y }); + } + } + + await action.release().perform(); + logInfo("End pan gesture!"); + } + + /** + * @experimental + * This method will try to move two fingers from opposite corners. + * One finger starts from + * { x: elementRect.x + offset.x, y: elementRect.y + offset.y } + * and ends to + * { x: elementRect.x + elementRect.width - offset.x, y: elementRect.height + elementRect.y - offset.y } + * and the other finger starts from + * { x: elementRect.width + elementRect.x - offset.x, y: elementRect.height + elementRect.y - offset.y } + * and ends to + * { x: elementRect.x + offset.x, y: elementRect.y + offset.y } + */ + public async rotate(offset: { x: number, y: number } = { x: 10, y: 10 }) { + logInfo("Start rotate gesture!"); + const elementRect = await this.getRectangle(); + + const startPoint = { x: elementRect.x + offset.x, y: elementRect.y + offset.y }; + const endPoint = { x: elementRect.x + elementRect.width - offset.x, y: elementRect.height + elementRect.y - offset.y }; + + const multiAction = new this._wd.MultiAction(this._driver); + const action1 = new this._wd.TouchAction(this._driver); + action1 + .press(startPoint) + .wait(100) + .moveTo(endPoint) + .wait(1000) + .release(); + multiAction.add(action1); + + const action2 = new this._wd.TouchAction(this._driver); + action2 + .press(endPoint) + .wait(100) + .moveTo({ x: startPoint.x, y: startPoint.y - 1 }) + .wait(1000) + .release(); + multiAction.add(action2); + + await multiAction.perform(); + logInfo("End rotate gesture!"); + } + + /** + * @experimental + * @param zoomFactory - zoomIn or zoomOut. Only zoomIn action is implemented + * @param offset + */ + public async pinch(zoomType: "in" | "out", offset?: { x: number, y: number }) { + logInfo("Start pinch gesture!"); + const elementRect = await this.getRectangle(); + + offset = offset || { x: elementRect.width / 2, y: elementRect.height / 2 }; + elementRect.y = elementRect.y + elementRect.height / 2; + + const endPoint = { x: offset.x, y: offset.y }; + + const startPointOne = { x: elementRect.x + 20, y: elementRect.y }; + const action1 = new this._wd.TouchAction(this._driver); + action1 + .press(startPointOne) + .wait(100) + .moveTo(endPoint) + .release() + + const multiAction = new this._wd.MultiAction(this._driver); + multiAction.add(action1); + + const startPointTwo = { x: elementRect.x + elementRect.width, y: elementRect.y }; + const action2 = new this._wd.TouchAction(this._driver); + action2 + .press(startPointTwo) + .wait(500) + .moveTo(endPoint) + .release() + multiAction.add(action2); + + await multiAction.perform(); + logInfo("End pinch gesture!"); } -} +} \ No newline at end of file diff --git a/lib/utils.d.ts b/lib/utils.d.ts index 764e2ca..2cd64f7 100644 --- a/lib/utils.d.ts +++ b/lib/utils.d.ts @@ -21,8 +21,9 @@ export declare const getStorage: (args: INsCapabilities) => string; export declare function getReportPath(args: INsCapabilities): string; export declare const getRegexResultsAsArray: (regex: any, str: any) => any[]; export declare function getAppPath(caps: INsCapabilities): string; -export declare function calculateOffset(direction: any, y: number, yOffset: number, x: number, xOffset: number, isIOS: boolean, verbose: any): { - point: Point; +export declare function calculateOffset(direction: any, y: number, yOffset: number, x: number, xOffset: number, isIOS: boolean): { + startPoint: Point; + endPoint: Point; duration: number; }; /** @@ -32,7 +33,7 @@ export declare function calculateOffset(direction: any, y: number, yOffset: numb * @param yOffset * @param xOffset */ -export declare function scroll(wd: any, driver: any, direction: Direction, isIOS: boolean, y: number, x: number, yOffset: number, xOffset: number, verbose: any): Promise; +export declare function scroll(wd: any, driver: any, direction: Direction, isIOS: boolean, y: number, x: number, yOffset: number, xOffset: number): Promise; export declare const addExt: (fileName: string, ext: string) => string; export declare const isPortAvailable: (port: any) => Promise<{}>; export declare const findFreePort: (retries?: number, port?: number) => Promise; @@ -51,6 +52,7 @@ export declare function logWarn(info: any, obj?: any): void; export declare function logError(info: any, obj?: any): void; export declare function log(message: any, verbose: any): void; export declare const logColorized: (bgColor: ConsoleColor, frontColor: ConsoleColor, info: any) => void; +export declare function adbShellCommand(wd: any, command: string, args: Array): Promise; declare enum ConsoleColor { Reset = "\u001B[0m", Bright = "\u001B[1m", diff --git a/lib/utils.ts b/lib/utils.ts index 7df007e..75985e3 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -242,7 +242,7 @@ export function getStorageByDeviceName(args: INsCapabilities) { storage = createStorageFolder(storage, getDeviceName(args)); logWarn(`Images storage set to: ${storage}!`); - + return storage; } @@ -381,48 +381,55 @@ export function getAppPath(caps: INsCapabilities) { return appFullPath; } -export function calculateOffset(direction, y: number, yOffset: number, x: number, xOffset: number, isIOS: boolean, verbose) { +export function calculateOffset(direction, y: number, yOffset: number, x: number, xOffset: number, isIOS: boolean) { let speed = 10; - let yEnd = Math.abs(yOffset); - let xEnd = Math.abs(xOffset); + let yEnd = y; + let xEnd = x; let duration = Math.abs(yEnd) * speed; - if (isIOS) { - speed = 100; - if (direction === Direction.down) { - direction = -1; - yEnd = direction * yEnd; - } - if (direction === Direction.right) { - direction = -1; - xEnd = direction * xEnd; - } - } else { - if (direction === Direction.down) { - yEnd = Math.abs(yOffset - y); - } - if (direction === Direction.up) { - yEnd = direction * Math.abs((Math.abs(yOffset) + y)); - } - + if (direction === Direction.down) { + yEnd = Math.abs(y); + y = Math.abs(yOffset - y); duration = Math.abs(yOffset) * speed; + } + if (direction === Direction.up) { + yEnd = Math.abs((Math.abs(y - yOffset))); + duration = Math.abs(yOffset) * speed; + } - if (direction === Direction.right) { - xEnd = Math.abs(xOffset - x); - } + if (direction === Direction.right) { + xEnd = Math.abs(x); + x = Math.abs(xOffset - x); + duration = Math.abs(xOffset) * speed; + } - if (direction === Direction.left) { - xEnd = Math.abs(xOffset + x); + if (direction === Direction.left) { + xEnd = Math.abs(xOffset + x); + duration = Math.abs(xOffset) * speed; + const addToX = isIOS ? 50 : 5; + if (isIOS) { + x = x === 0 ? 50 : x; + } else { + x = x === 0 ? 5 : x; } - - if (yOffset < xOffset && x) { - duration = Math.abs(xOffset) * speed; + if (x === 0) { + logInfo(`Changing x to x + ${addToX}, since this will perform navigate back for ios or rise exception in android!`); } + } + if (yOffset < xOffset) { + duration = Math.abs(xOffset) * speed; } - log({ point: new Point(xEnd, yEnd), duration: duration }, verbose); - return { point: new Point(xEnd, yEnd), duration: duration }; + logInfo("Start point: ", new Point(x, y)); + logInfo("End point: ", new Point(xEnd, yEnd)); + logInfo("Scrolling speed: ", duration); + + return { + startPoint: new Point(x, y), + endPoint: new Point(xEnd, yEnd), + duration: duration + }; } /** @@ -432,19 +439,19 @@ export function calculateOffset(direction, y: number, yOffset: number, x: number * @param yOffset * @param xOffset */ -export async function scroll(wd, driver, direction: Direction, isIOS: boolean, y: number, x: number, yOffset: number, xOffset: number, verbose) { +export async function scroll(wd, driver, direction: Direction, isIOS: boolean, y: number, x: number, yOffset: number, xOffset: number) { if (x === 0) { x = 20; } if (y === 0) { y = 20; } - const endPoint = calculateOffset(direction, y, yOffset, x, xOffset, isIOS, verbose); + const endPoint = calculateOffset(direction, y, yOffset, x, xOffset, isIOS); const action = new wd.TouchAction(driver); action .press({ x: x, y: y }) .wait(endPoint.duration) - .moveTo({ x: endPoint.point.x, y: endPoint.point.y }) + .moveTo({ x: endPoint.endPoint.x, y: endPoint.endPoint.y }) .release(); await action.perform(); await driver.sleep(150); @@ -687,6 +694,11 @@ export const logColorized = (bgColor: ConsoleColor, frontColor: ConsoleColor, in console.log(`${ConsoleColor.BgYellow}${ConsoleColor.FgBlack}%s${ConsoleColor.Reset}`, info); } + +export async function adbShellCommand(wd: any, command: string, args: Array) { + await wd.execute('mobile: shell', { "command": command, "args": args }); +} + enum ConsoleColor { Reset = "\x1b[0m", Bright = "\x1b[1m", diff --git a/package.json b/package.json index 8988766..4f0b86e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nativescript-dev-appium", - "version": "6.0.0", + "version": "6.1.3", "description": "A NativeScript plugin to help integrate and run Appium tests", "author": "NativeScript", "license": "MIT", @@ -33,12 +33,10 @@ "dependencies": { "app-root-path": "~2.1.0", "blink-diff": "~1.0.13", - "chai": "~4.2.0", - "chai-as-promised": "~7.1.0", "frame-comparer": "^2.0.1", "glob": "^7.1.0", "inquirer": "^6.2.0", - "mobile-devices-controller": "~5.0.0", + "mobile-devices-controller": "~5.2.0", "wd": "~1.11.3", "webdriverio": "~4.14.0", "yargs": "~12.0.5" diff --git a/samples/e2e-ts/tsconfig.json b/samples/e2e-ts/tsconfig.json index 8f790fe..b6946e2 100644 --- a/samples/e2e-ts/tsconfig.json +++ b/samples/e2e-ts/tsconfig.json @@ -5,6 +5,7 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true, "importHelpers": false, + "sourceMap": true, "types": [ ], "lib": [ "es2015", diff --git a/samples/e2e-ts/typescript.mocha.sample.e2e-spec.mochawesome.ts b/samples/e2e-ts/typescript.mocha.sample.e2e-spec.mochawesome.ts index 0d1a2ac..ccd4a08 100644 --- a/samples/e2e-ts/typescript.mocha.sample.e2e-spec.mochawesome.ts +++ b/samples/e2e-ts/typescript.mocha.sample.e2e-spec.mochawesome.ts @@ -2,7 +2,7 @@ import { AppiumDriver, createDriver, SearchOptions, nsCapabilities } from "nativ import { assert } from "chai"; const addContext = require('mochawesome/addContext'); -describe("sample scenario", () => { +describe("sample scenario", async function(){ let driver: AppiumDriver; before(async function(){ diff --git a/test/device-manager.spec.ts b/test/device-manager.spec.ts index bec3568..163b998 100644 --- a/test/device-manager.spec.ts +++ b/test/device-manager.spec.ts @@ -206,8 +206,8 @@ describe("start-appium-server-ios", async () => { appPath: iosApp, appiumCaps: { platformName: Platform.IOS, - deviceName: /^iPhone XR$/, - platformVersion: /12/, + deviceName: /^iPhone XR 12$/, + platformVersion: "12.2", fullReset: true }, verbose: false @@ -275,7 +275,7 @@ describe("dev-mode-options", async () => { before("start devices", async () => { await DeviceController.startDevice({ platform: Platform.ANDROID, apiLevel: "23" }); - await DeviceController.startDevice({ platform: Platform.IOS, apiLevel: /12./, name: "iPhone XR" }); + await DeviceController.startDevice({ platform: Platform.IOS, apiLevel: "12.2", name: "iPhone XR 12" }); appiumServer = new AppiumServer({}); await appiumServer.start(8399); });