Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add device.tap() and device.longPress(). #4542

Merged
merged 5 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading