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

Open
wants to merge 33 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a208592
add tap android and basic test
gayablau Jul 28, 2024
2114892
update tests
gayablau Jul 29, 2024
cc494b9
remove only from test
gayablau Jul 29, 2024
cb93597
add long press api
gayablau Jul 30, 2024
66a7ecc
android api improvements
gayablau Aug 1, 2024
760960b
wip ios tap
gayablau Aug 1, 2024
f5f6267
Merge branch 'master' into feat/device-tap
gayablau Aug 1, 2024
a6058c7
improve decleration
gayablau Aug 1, 2024
c5aec9b
Merge branch 'feat/device-tap' of https://github.com/wix/Detox into f…
gayablau Aug 1, 2024
5c1dc73
Merge branch 'feat/device-tap' of https://github.com/wix/Detox into f…
gayablau Aug 1, 2024
b7c7252
clean
gayablau Aug 1, 2024
6cb90ea
wip
gayablau Aug 1, 2024
ae6b2b9
some cleanup
gayablau Aug 1, 2024
aa7974d
map arguments'
gayablau Aug 4, 2024
3895b6d
Merge branch 'master' into feat/device-tap
asafkorem Aug 7, 2024
37fdfbd
wip
gayablau Aug 8, 2024
9eaf52f
fix tap
gayablau Aug 8, 2024
166c680
clean code
gayablau Sep 5, 2024
4082fcd
add prop shouldIgnoreStatusBar
gayablau Sep 6, 2024
a8173e3
fix test
gayablau Sep 6, 2024
083b836
Merge branch 'feat/device-tap' of https://github.com/wix/Detox into f…
gayablau Sep 6, 2024
c4c214c
Merge branch 'master' of https://github.com/wix/Detox into feat/devic…
gayablau Sep 6, 2024
e708f58
Merge branch 'feat/device-tap' of https://github.com/wix/Detox into f…
gayablau Sep 6, 2024
fb1ebc0
fix for ios
gayablau Sep 6, 2024
6210ec0
long press ios
gayablau Sep 6, 2024
e45373b
clean and add tests
gayablau Sep 6, 2024
6ded4cb
expand tests
gayablau Sep 7, 2024
1d8f092
documentation
gayablau Sep 7, 2024
adbed53
clean
gayablau Sep 7, 2024
cf4c84e
clean
gayablau Sep 7, 2024
d82c1ee
fix unit tests
gayablau Sep 7, 2024
38c4e58
Merge branch 'master' into feat/device-tap-ios
gayablau Nov 6, 2024
5f6c1c4
device tap and long press review
gayablau Nov 6, 2024
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 @@ -121,5 +121,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 = shouldIgnoreStatusBar ? y + UiAutomatorHelper.getStatusBarHeight(view) : y;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's extract that to private method that will be shared with the longPress's perform (reuse & separation of concerns) → e.g. calculateAdjustedY(y, shouldIgnoreStatusBar) or something similar.

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 = shouldIgnoreStatusBar ? y + UiAutomatorHelper.getStatusBarHeight(view) : y;
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 getStatusBarHeight(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));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to avoid confusion, could you please exctrat the method with the same name from isDisplayingAtLeastDetoxMatcher to this file? They perform a similar task but return coordinates in different units (px vs dp), so I would suggest creating two distinct method names to differentiate them:

  • getStatusBarHeightPixels
  • getStatusBarHeightDps

@SuppressLint("InternalInsetResource", "DiscouragedApi")
private fun getStatusBarHeight(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
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a scout rule that'll make the code cleaner 🙏

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the names, do you mean to move one of the functions?

}
39 changes: 39 additions & 0 deletions detox/detox.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,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,96 @@ import Foundation
import XCTest

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

let exists = element.waitForExistence(timeout: .defaultTimeout)
DTXAssert(
exists,
"Action failed, element with matcher `\(params.matcherDescription)` does not exist"
)

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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please reindent


func getNormalizedCoordinate(from params: InvocationParams) throws -> XCUICoordinate {
do {
guard let x = Int(params.params?.first ?? "100"), let y = Int(params.params?[1] ?? "100") else {
throw Error.missingTypeTextParam
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're throwing this error but catching it and hiding it (with failedToTapDeviceByCoordinates, see below).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also this is not the right error to throw, you might want to make this error more generic by introducing params to it, e.g. missingParam(paramName, paramType)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the error because we want to allow this case, I just need the fallback

}

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's avoid repeated calls to XCUIApplication.appUnderTest() by storing it in a variable


return coordinate;
} catch {
throw Error.failedToTapDeviceByCoordinates
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're going to throw this error anyway if you catch anything (Error.failedToTapDeviceByCoordinates) since you already wrapped this method with try-catch on the switch-case (on handle)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may avoid the try-catch here and just let it be caught by the caller, or remove the duplicated try-catch from the caller(s).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not even sure if we need any of these catches since they might hide other errors that are more specific. Please revisit the error throwing logic.

}

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
83 changes: 83 additions & 0 deletions detox/src/android/espressoapi/EspressoDetox.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,89 @@ class EspressoDetox {
};
}

static tap(x, y, shouldIgnoreStatusBar) {
if (typeof x !== "number") throw new Error("x should be a number, but got " + (x + (" (" + (typeof x + ")"))));
if (typeof y !== "number") throw new Error("y should be a number, but got " + (y + (" (" + (typeof y + ")"))));
if (typeof shouldIgnoreStatusBar !== "boolean") throw new Error("shouldIgnoreStatusBar should be a boolean, but got " + (shouldIgnoreStatusBar + (" (" + (typeof shouldIgnoreStatusBar + ")"))));
return {
target: {
type: "Class",
value: "com.wix.detox.espresso.EspressoDetox"
},
method: "tap",
args: [{
type: "Integer",
value: x
}, {
type: "Integer",
value: y
}, {
type: "boolean",
value: shouldIgnoreStatusBar
}]
};
}

static longPress(x, y, shouldIgnoreStatusBar) {
function longPress3(x, y, shouldIgnoreStatusBar) {
if (typeof x !== "number") throw new Error("x should be a number, but got " + (x + (" (" + (typeof x + ")"))));
if (typeof y !== "number") throw new Error("y should be a number, but got " + (y + (" (" + (typeof y + ")"))));
if (typeof shouldIgnoreStatusBar !== "boolean") throw new Error("shouldIgnoreStatusBar should be a boolean, but got " + (shouldIgnoreStatusBar + (" (" + (typeof shouldIgnoreStatusBar + ")"))));
return {
target: {
type: "Class",
value: "com.wix.detox.espresso.EspressoDetox"
},
method: "longPress",
args: [{
type: "Integer",
value: x
}, {
type: "Integer",
value: y
}, {
type: "boolean",
value: shouldIgnoreStatusBar
}]
};
}

function longPress4(x, y, duration, shouldIgnoreStatusBar) {
if (typeof x !== "number") throw new Error("x should be a number, but got " + (x + (" (" + (typeof x + ")"))));
if (typeof y !== "number") throw new Error("y should be a number, but got " + (y + (" (" + (typeof y + ")"))));
if (typeof duration !== "number") throw new Error("duration should be a number, but got " + (duration + (" (" + (typeof duration + ")"))));
if (typeof shouldIgnoreStatusBar !== "boolean") throw new Error("shouldIgnoreStatusBar should be a boolean, but got " + (shouldIgnoreStatusBar + (" (" + (typeof shouldIgnoreStatusBar + ")"))));
return {
target: {
type: "Class",
value: "com.wix.detox.espresso.EspressoDetox"
},
method: "longPress",
args: [{
type: "Integer",
value: x
}, {
type: "Integer",
value: y
}, {
type: "Integer",
value: duration
}, {
type: "boolean",
value: shouldIgnoreStatusBar
}]
};
}

if (arguments.length === 3) {
return longPress3.apply(null, arguments);
}

if (arguments.length === 4) {
return longPress4.apply(null, arguments);
}
}

}

module.exports = EspressoDetox;
Loading
Loading