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(android): add device.tap() and device.longPress(). #4534

Closed
wants to merge 16 commits into from
Closed
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 @@ -121,5 +121,52 @@ public void run() {
}
});
}

public static void tap(Integer x, Integer y) {
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 = true ? y + UiAutomatorHelper.getStatusBarHeight(view) : y;
ViewAction action = DetoxAction.tapAtLocation(x, adjustedY);
action.perform(uiController, view);
uiController.loopMainThreadUntilIdle();
}
});
}

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

public static void longPress(Integer x, Integer y, Integer duration) {
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) {
ViewAction action = DetoxAction.longPress(x, y, 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,11 @@ 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));
//return resourceId > 0 ? context.getResources().getDimensionPixelSize(resourceId) : 0;
}
}
25 changes: 25 additions & 0 deletions detox/detox.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,31 @@ declare global {
*/
setOrientation(orientation: Orientation): Promise<void>;

/**
* Perform a tap at arbitrary coordinates on the default display specified by the user. Currently only available in the Android Simulator.
* @param point coordinates in the element's coordinate space. Optional (default is the center of the element). defaults: x: 100, y: 100
* @example await device.tap();
* @example await device.tap({ x: 100, y: 150 });
*/
tap(): Promise<void>;
tap(point: Point2D): Promise<void>;

/**
* Perform a long press at arbitrary coordinates on the default display specified by the user. Custom press duration if needed. Currently only available in the Android Simulator.
* @param point coordinates in the element's coordinate space. Optional (default is the center of the element). defaults: x: 100, y: 100
* @param duration custom press duration time, in milliseconds. Optional (defaults to the standard long-press duration for the platform).
* 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.
* @example await device.longPress();
* @example await device.longPress({ x: 100, y: 150 });
* @example await device.longPress(2000);
* @example await device.longPress({ x: 100, y: 150 }, 2000);
*/
longPress(): Promise<void>;
longPress(point: Point2D): Promise<void>;
longPress(duration: number): Promise<void>;
longPress(point: Point2D, duration: number): Promise<void>;

/**
* Sets the simulator/emulator location to the given latitude and longitude.
*
Expand Down
71 changes: 71 additions & 0 deletions detox/src/android/espressoapi/EspressoDetox.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,77 @@ class EspressoDetox {
};
}

static tap(x, y) {
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 + ")"))));
return {
target: {
type: "Class",
value: "com.wix.detox.espresso.EspressoDetox"
},
method: "tap",
args: [{
type: "Integer",
value: x
}, {
type: "Integer",
value: y
}]
};
}

static longPress(x, y) {
function longPress2(x, y) {
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 + ")"))));
return {
target: {
type: "Class",
value: "com.wix.detox.espresso.EspressoDetox"
},
method: "longPress",
args: [{
type: "Integer",
value: x
}, {
type: "Integer",
value: y
}]
};
}

function longPress3(x, y, duration) {
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 + ")"))));
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
}]
};
}

if (arguments.length === 2) {
return longPress2.apply(null, arguments);
}

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

}

module.exports = EspressoDetox;
11 changes: 11 additions & 0 deletions detox/src/devices/runtime/RuntimeDevice.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const log = require('../../utils/logger').child({ cat: 'device' });
const traceMethods = require('../../utils/traceMethods');
const wrapWithStackTraceCutter = require('../../utils/wrapWithStackTraceCutter');
const mapLongPressArguments = require('../../utils/mapLongPressArguments');

Check failure on line 6 in detox/src/devices/runtime/RuntimeDevice.js

View workflow job for this annotation

GitHub Actions / Linux

`../../utils/mapLongPressArguments` import should occur before import of `../../utils/traceMethods`

const LaunchArgsEditor = require('./utils/LaunchArgsEditor');

Expand Down Expand Up @@ -283,6 +284,16 @@
await this.deviceDriver.setOrientation(orientation);
}

async tap(point, shouldIgnoreStatusBar) {
await this.deviceDriver.tap(point, shouldIgnoreStatusBar);
}

async longPress(arg1, arg2) {
let { point, duration } = mapLongPressArguments(arg1, arg2);

await this.deviceDriver.longPress(point, duration);
}

async setLocation(lat, lon) {
lat = String(lat);
lon = String(lon);
Expand Down
17 changes: 17 additions & 0 deletions detox/src/devices/runtime/RuntimeDevice.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,23 @@ describe('Device', () => {
expect(driverMock.driver.setOrientation).toHaveBeenCalledWith('param');
});

it(`tap() should pass to device driver`, async () => {
const device = await aValidDevice();
const points = { x: 200, y: 200 };
await device.tap(points);

expect(driverMock.driver.tap).toHaveBeenCalledWith(points);
});

it(`longPress() should pass to device driver`, async () => {
const device = await aValidDevice();
const points = { x: 200, y: 200 };
const duration = 2000;
await device.longPress(points, duration);

expect(driverMock.driver.longPress).toHaveBeenCalledWith(points, duration);
});

it(`sendUserNotification() should pass to device driver`, async () => {
const device = await aValidDevice();
await device.sendUserNotification('notif');
Expand Down
8 changes: 8 additions & 0 deletions detox/src/devices/runtime/drivers/DeviceDriverBase.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@
return '';
}

async tap(point, shouldIgnoreStatusBar) {

Check warning on line 54 in detox/src/devices/runtime/drivers/DeviceDriverBase.js

View workflow job for this annotation

GitHub Actions / Linux

'point' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 54 in detox/src/devices/runtime/drivers/DeviceDriverBase.js

View workflow job for this annotation

GitHub Actions / Linux

'shouldIgnoreStatusBar' is defined but never used. Allowed unused args must match /^_/u
return '';
}

async longPress(arg1, arg2) {

Check warning on line 58 in detox/src/devices/runtime/drivers/DeviceDriverBase.js

View workflow job for this annotation

GitHub Actions / Linux

'arg1' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 58 in detox/src/devices/runtime/drivers/DeviceDriverBase.js

View workflow job for this annotation

GitHub Actions / Linux

'arg2' is defined but never used. Allowed unused args must match /^_/u
return '';
}

async sendToHome() {
return '';
}
Expand Down
14 changes: 14 additions & 0 deletions detox/src/devices/runtime/drivers/android/AndroidDriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,20 @@
await this.invocationManager.execute(call);
}

async tap(point, shouldIgnoreStatusBar = false) {
let x = point?.x ?? 100;
let y = point?.y ?? 100;
const call = EspressoDetoxApi.tap(x, y, shouldIgnoreStatusBar);
await this.invocationManager.execute(call);
}

async longPress(point, duration) {
let x = point?.x ?? 100;
let y = point?.y ?? 100;
const call = !!duration ? EspressoDetoxApi.longPress(x, y, duration): EspressoDetoxApi.longPress(x, y);

Check failure on line 258 in detox/src/devices/runtime/drivers/android/AndroidDriver.js

View workflow job for this annotation

GitHub Actions / Linux

Redundant double negation
await this.invocationManager.execute(call);
}

async generateViewHierarchyXml(shouldInjectTestIds) {
const hierarchy = await this.invocationManager.execute(DetoxApi.generateViewHierarchyXml(shouldInjectTestIds));
return hierarchy.result;
Expand Down
39 changes: 39 additions & 0 deletions detox/test/e2e/06.device.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,43 @@ describe('Device', () => {
await device.pressBack();
await expect(element(by.text('Back pressed !'))).toBeVisible();
});

it(':android: tap on screen', async () => {
await device.launchApp({ newInstance: true });
await element(by.text('Device Tap')).tap();
await device.tap();
await expect(element(by.text('Screen Tapped'))).toBeVisible();
});

it(':android: tap on screen by coordinates', async () => {
await device.launchApp({ newInstance: true });
await element(by.text('Device Tap')).tap();
const point = {x: 210, y: 200}
await device.tap(point, true);
await expect(element(by.text('Button Tapped'))).toBeVisible();
});

it(':android: long press on screen by coordinates', async () => {
await device.launchApp({ newInstance: true });
await element(by.text('Device Tap')).tap();
const point = {x: 150, y: 300}
await device.longPress(point);
await expect(element(by.text('Screen Long Pressed'))).toBeVisible();
});

it(':android: long press on screen by coordinates with duration', async () => {
await device.launchApp({ newInstance: true });
await element(by.text('Device Tap')).tap();
const point = {x: 200, y: 190}
await device.longPress(point, 2000);
await expect(element(by.text('Screen Long Custom Duration Pressed'))).toBeVisible();
});

it(':android: long press on screen by coordinates with duration', async () => {
await device.launchApp({ newInstance: true });
await element(by.text('Device Tap')).tap();
const point = {x: 200, y: 190}
await device.longPress(2000);
await expect(element(by.text('Screen Long Custom Duration Pressed'))).toBeVisible();
});
});
43 changes: 43 additions & 0 deletions detox/test/src/Screens/DeviceTapScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { useCallback, useState } from 'react';
import { Button, Text, TouchableOpacity, View } from 'react-native';

const DeviceTapScreen = () => {
const [shouldShowScreenText, setShouldShowScreenText] = useState(false);
const [shouldShowButtonText, setShouldShowButtonText] = useState(false);
const [shouldShowLongText, setShouldShowLongText] = useState(false);
const [shouldShowLongCustomText, setShouldShowLongCustomText] = useState(false);

const onPressLongCustom = useCallback(() => {
setShouldShowLongCustomText(true);
}, [setShouldShowLongCustomText]);

const onPressScreen = useCallback(() => {
setShouldShowScreenText(true);
}, [setShouldShowScreenText]);

const onPressButton = useCallback(() => {
setShouldShowButtonText(true);
}, [setShouldShowButtonText]);

const onPressLong = useCallback(() => {
setShouldShowLongText(true);
}, [setShouldShowLongText]);

return (<>
<TouchableOpacity onLongPress={onPressLong} onPress={onPressScreen} style={{ flex: 1 }}>
<View style={{ left: 190, top: 190, position: 'absolute' }}>
<Button onPress={onPressButton} title="TAP ME" />
</View>
<TouchableOpacity delayLongPress={1500} onLongPress={onPressLongCustom} style={{ left: 150, top: 150, position: 'absolute' }}>
<Text>{'TAP LONG TIME'}</Text>
</TouchableOpacity>
{shouldShowLongCustomText && <Text>{'Screen Long Custom Duration Pressed'}</Text>}
{shouldShowLongText && <Text>{'Screen Long Pressed'}</Text>}
{shouldShowScreenText && <Text>{'Screen Tapped'}</Text>}
{shouldShowButtonText && <Text>{'Button Tapped'}</Text>}
</TouchableOpacity>
</>
);
};

export default DeviceTapScreen;
2 changes: 2 additions & 0 deletions detox/test/src/Screens/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import LaunchArgsScreen from './LaunchArgsScreen';
import LaunchNotificationScreen from './LaunchNotificationScreen';
import PickerViewScreen from './PickerViewScreen';
import DeviceScreen from './DeviceScreen';
import DeviceTapScreen from './DeviceTapScreen';
import OverlayScreen from './OverlayScreen';
import ElementScreenshotScreen from './ElementScreenshotScreen';
import VirtualizedListStressScreen from './VirtualizedListStressScreen';
Expand Down Expand Up @@ -58,6 +59,7 @@ export {
LaunchArgsScreen,
LaunchNotificationScreen,
DeviceScreen,
DeviceTapScreen,
OverlayScreen,
ElementScreenshotScreen,
WebViewScreen,
Expand Down
1 change: 1 addition & 0 deletions detox/test/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export default class example extends Component {
{this.renderScreenButton('Network', Screens.NetworkScreen)}
{this.renderAnimationScreenButtons()}
{this.renderScreenButton('Device', Screens.DeviceScreen)}
{isAndroid && this.renderScreenButton('Device Tap', Screens.DeviceTapScreen)}
{isIos && this.renderScreenButton('Overlay', Screens.OverlayScreen)}
{this.renderScreenButton('Location', Screens.LocationScreen)}
{this.renderScreenButton('DatePicker', Screens.DatePickerScreen)}
Expand Down
Loading