Skip to content

Commit

Permalink
Merge pull request #4542 from wix/feat/device-tap-ios
Browse files Browse the repository at this point in the history
feat: add `device.tap()` and `device.longPress()`.
  • Loading branch information
asafkorem authored Nov 14, 2024
2 parents 532f696 + d48482d commit b4554de
Show file tree
Hide file tree
Showing 27 changed files with 768 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,22 @@

import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.ViewInteraction;
import androidx.test.espresso.NoMatchingViewException;
import androidx.test.platform.app.InstrumentationRegistry;

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static com.wix.detox.espresso.UiAutomatorHelper.getStatusBarHeightDps;

/**
* Created by rotemm on 26/12/2016.
*/
public class EspressoDetox {
private static final String LOG_TAG = "detox";

private static int calculateAdjustedY(View view, Integer y, boolean shouldIgnoreStatusBar) {
return shouldIgnoreStatusBar ? y + getStatusBarHeightDps(view) : y;
}

public static Object perform(Matcher<View> matcher, ViewAction action) {
ViewActionPerformer performer = ViewActionPerformer.forAction(action);
return performer.performOn(matcher);
Expand Down Expand Up @@ -121,5 +124,53 @@ public void run() {
}
});
}

public static void tap(Integer x, Integer y, boolean shouldIgnoreStatusBar) {
onView(isRoot()).perform(new ViewAction() {
@Override
public Matcher<View> getConstraints() {
return isRoot();
}

@Override
public String getDescription() {
return "tap on screen";
}

@Override
public void perform(UiController uiController, View view) {
int adjustedY = calculateAdjustedY(view, y, shouldIgnoreStatusBar);
ViewAction action = DetoxAction.tapAtLocation(x, adjustedY);
action.perform(uiController, view);
uiController.loopMainThreadUntilIdle();
}
});
}

public static void longPress(Integer x, Integer y, boolean shouldIgnoreStatusBar) {
longPress(x, y, null, shouldIgnoreStatusBar);
}

public static void longPress(Integer x, Integer y, Integer duration, boolean shouldIgnoreStatusBar) {
onView(isRoot()).perform(new ViewAction() {
@Override
public Matcher<View> getConstraints() {
return isRoot();
}

@Override
public String getDescription() {
return "long press on screen";
}

@Override
public void perform(UiController uiController, View view) {
int adjustedY = calculateAdjustedY(view, y, shouldIgnoreStatusBar);
ViewAction action = DetoxAction.longPress(x, adjustedY, duration);
action.perform(uiController, view);
uiController.loopMainThreadUntilIdle();
}
});
}
}

Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package com.wix.detox.espresso;

import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Handler;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.TypedValue;
import android.view.Choreographer;
import android.view.View;

import com.wix.detox.common.UIThread;
import com.wix.detox.espresso.action.common.utils.UiControllerUtils;
Expand Down Expand Up @@ -111,4 +116,10 @@ public void doFrame(long frameTimeNanos) {
}
}

@SuppressLint({"DiscouragedApi", "InternalInsetResource"})
public static int getStatusBarHeightDps(View view) {
Context context = view.getContext();
int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android");
return (int) (context.getResources().getDimensionPixelSize(resourceId) / ((float) context.getResources().getDisplayMetrics().densityDpi / DisplayMetrics.DENSITY_DEFAULT));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ class IsDisplayingAtLeastDetoxMatcher(private val areaPercentage: Int) : TypeSaf
.defaultDisplay
.getMetrics(m)

val statusBarHeight = getStatusBarHeight(view)
val statusBarHeight = getStatusBarHeightPixels(view)
val actionBarHeight = getActionBarHeight(view)
return Rect(0, 0, m.widthPixels, m.heightPixels - (statusBarHeight + actionBarHeight))
}
Expand All @@ -138,7 +138,7 @@ class IsDisplayingAtLeastDetoxMatcher(private val areaPercentage: Int) : TypeSaf
}

@SuppressLint("InternalInsetResource", "DiscouragedApi")
private fun getStatusBarHeight(view: View): Int {
private fun getStatusBarHeightPixels(view: View): Int {
val resourceId = view.context.resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) view.context.resources.getDimensionPixelSize(resourceId) else 0
}
Expand Down
39 changes: 39 additions & 0 deletions detox/detox.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,45 @@ declare global {
*/
setOrientation(orientation: Orientation): Promise<void>;

/**
* Perform a tap at arbitrary coordinates on the device's screen.
* @param point Coordinates in the element's coordinate space. Optional. defaults: x: 100, y: 100
* @param shouldIgnoreStatusBar Coordinates will be measured starting from under the status bar. this param will affect only in Android tests. Optional. default: true
* @example await device.tap();
* @example await device.tap({ x: 100, y: 150 }, false);
* @example await device.tap({ x: 100, y: 150 });
* @example await device.tap(false);
*/
tap(): Promise<void>;
tap(point: Point2D): Promise<void>;
tap(point: Point2D, shouldIgnoreStatusBar: boolean): Promise<void>;
tap(shouldIgnoreStatusBar: boolean): Promise<void>;

/**
* Perform a long press at arbitrary coordinates on the device's screen. Custom press duration if needed.
* @param point Coordinates in the device's coordinate space. Optional. defaults: x: 100, y: 100
* @param duration Custom press duration time, in milliseconds. Optional (defaults to the standard long-press duration for Android and 1000 milliseconds for ios).
* Custom durations should be used cautiously, as they can affect test consistency and user experience expectations.
* They are typically necessary when testing components that behave differently from the platform's defaults or when simulating unique user interactions.
* @param shouldIgnoreStatusBar Coordinates will be measured starting from under the status bar. this param will affect only in Android tests. Optional. default: true
* @example await device.longPress();
* @example await device.longPress({ x: 100, y: 150 }, 2000, false);
* @example await device.longPress({ x: 100, y: 150 }, 2000);
* @example await device.longPress(2000, false);
* @example await device.longPress({ x: 100, y: 150 }, false);
* @example await device.longPress({ x: 100, y: 150 });
* @example await device.longPress(2000);
* @example await device.longPress(false);
*/
longPress(): Promise<void>;
longPress(point: Point2D, duration: number, shouldIgnoreStatusBar: boolean): Promise<void>;
longPress(point: Point2D, duration: number): Promise<void>;
longPress(duration: number, shouldIgnoreStatusBar: boolean): Promise<void>;
longPress(point: Point2D, shouldIgnoreStatusBar: boolean): Promise<void>;
longPress(point: Point2D): Promise<void>;
longPress(duration: number): Promise<void>;
longPress(shouldIgnoreStatusBar: boolean): Promise<void>;

/**
* Sets the simulator/emulator location to the given latitude and longitude.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,12 @@ final class DetoxXCUITestRunner: XCTestCase {
appUnderTest: appUnderTest
)

let element = predicateHandler.findElement(using: params)

switch params.type {
case .systemAction, .webAction:
try actionHandler.handle(from: params, on: element)
try actionHandler.handle(from: params, predicateHandler: predicateHandler)

case .systemExpectation, .webExpectation:
try expectationHandler.handle(from: params, on: element)
try expectationHandler.handle(from: params, predicateHandler: predicateHandler)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ import Foundation

extension InvocationParams {
var matcherDescription: String {
return predicate.description
return predicate?.description ?? "none"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,48 +7,91 @@ import Foundation
import XCTest

class ActionHandler {
func handle(from params: InvocationParams, on element: XCUIElement) throws {

func findElement(from params: InvocationParams, predicateHandler: PredicateHandler) -> XCUIElement {
let element = predicateHandler.findElement(using: params)
let exists = element.waitForExistence(timeout: .defaultTimeout)
DTXAssert(
exists,
"Action failed, element with matcher `\(params.matcherDescription)` does not exist"
)
return element
}

func getNormalizedCoordinate(from params: InvocationParams) throws -> XCUICoordinate {
let x = Int(params.params?.first ?? "100") ?? 100
let y = Int(params.params?[1] ?? "100") ?? 100

let appUnderTest = try XCUIApplication.appUnderTest()
let screenFrame = appUnderTest.frame
let normalizedX = CGFloat(x) / screenFrame.width
let normalizedY = CGFloat(y) / screenFrame.height
let normalizedPoint = CGVector(dx: normalizedX, dy: normalizedY)
let coordinate = appUnderTest.coordinate(
withNormalizedOffset: normalizedPoint)

return coordinate
}

func handle(from params: InvocationParams, predicateHandler: PredicateHandler) throws {

guard let action = params.action else { return }
switch action {
case .tap:
let element = findElement(from: params, predicateHandler: predicateHandler);
element.tap()

case .typeText:
guard let text = params.params?.first else {
throw Error.missingTypeTextParam
}

let element = findElement(from: params, predicateHandler: predicateHandler);
element.typeTextOnEnd(text)

case .replaceText:
guard let text = params.params?.first else {
throw Error.missingTypeTextParam
}

let element = findElement(from: params, predicateHandler: predicateHandler);
element.replaceText(text)

case .clearText:
let element = findElement(from: params, predicateHandler: predicateHandler);
element.clearText()

case .coordinateTap:
do {
try getNormalizedCoordinate(from: params).tap();
} catch {
throw Error.failedToTapDeviceByCoordinates
}
case .coordinateLongPress:
guard let pressDuration = Double(params.params?[2] ?? "1") else { throw Error.missingTypeTextParam
}

do {
try getNormalizedCoordinate(from: params).press(forDuration: pressDuration);
} catch {
throw Error.failedToTapDeviceByCoordinates
}
}
}
}

extension ActionHandler {
enum Error: Swift.Error, LocalizedError {
case missingTypeTextParam
case failedToTapDeviceByCoordinates

var errorDescription: String? {
switch self {
case .missingTypeTextParam:
return "Missing text param for type action"
}
case .failedToTapDeviceByCoordinates:
return "Failed to perform tap action by coordinates"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import Foundation
import XCTest

class ExpectationHandler {
func handle(from params: InvocationParams, on element: XCUIElement) throws {
func handle(from params: InvocationParams, predicateHandler: PredicateHandler) throws {
guard let expectation = params.expectation else {
throw Error.invalidInvocationParams("Expectation type is missing")
}

let element = predicateHandler.findElement(using: params)
let expectedEvaluation = expectedEvaluation(params)

switch expectation {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ class PredicateHandler {
}

func findElement(using params: InvocationParams) -> XCUIElement {
let predicate = params.predicate
guard let predicate = params.predicate else {
fatalError("expected predicate param")
}
let query: XCUIElementQuery

switch params.type {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation

struct InvocationParams: Codable {
let type: InvocationType
let predicate: Predicate
let predicate: Predicate?
let atIndex: Int?
let action: Action?
let expectation: Expectation?
Expand Down Expand Up @@ -34,7 +34,7 @@ struct InvocationParams: Codable {
} else if let webPredicate = try? container.decode(Predicate.self, forKey: .webPredicate) {
predicate = webPredicate
} else {
throw Error.dataCorruptedError("predicate")
predicate = nil
}

// Handle both systemAtIndex and webAtIndex for the atIndex property
Expand Down Expand Up @@ -157,6 +157,8 @@ extension InvocationParams.Predicate {
extension InvocationParams {
enum Action: String, Codable {
case tap
case coordinateTap
case coordinateLongPress
case typeText
case replaceText
case clearText
Expand Down
Loading

0 comments on commit b4554de

Please sign in to comment.