diff --git a/detox/android/detox/src/coreNative/java/com/wix/detox/actions/DetoxViewActions.kt b/detox/android/detox/src/coreNative/java/com/wix/detox/actions/DetoxViewActions.kt index 144248b981..74729150f9 100644 --- a/detox/android/detox/src/coreNative/java/com/wix/detox/actions/DetoxViewActions.kt +++ b/detox/android/detox/src/coreNative/java/com/wix/detox/actions/DetoxViewActions.kt @@ -9,14 +9,14 @@ import com.wix.detox.action.common.MOTION_DIR_DOWN import com.wix.detox.action.common.MOTION_DIR_LEFT import com.wix.detox.action.common.MOTION_DIR_RIGHT import com.wix.detox.action.common.MOTION_DIR_UP -import com.wix.detox.espresso.action.DetoxMultiTap +import com.wix.detox.espresso.action.DetoxCustomTapper import com.wix.detox.espresso.scroll.DetoxScrollAction public object DetoxViewActions { public fun tap() = multiTap(1) public fun doubleTap() = multiTap(2) public fun multiTap(times: Int): ViewAction = - actionWithAssertions(GeneralClickAction(DetoxMultiTap(times), GeneralLocation.CENTER, Press.FINGER, 0, 0)) + actionWithAssertions(GeneralClickAction(DetoxCustomTapper(times), GeneralLocation.CENTER, Press.FINGER, 0, 0)) public fun scrollUpBy(amountInDp: Double, startOffsetPercentX: Float? = null, startOffsetPercentY: Float? = null): ViewAction = actionWithAssertions(DetoxScrollAction(MOTION_DIR_UP, amountInDp, startOffsetPercentX, startOffsetPercentY)) diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java index 1486267a5f..1f0b6362ce 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java @@ -20,7 +20,7 @@ import com.wix.detox.common.DetoxErrors.DetoxRuntimeException; import com.wix.detox.common.DetoxErrors.StaleActionException; import com.wix.detox.espresso.action.AdjustSliderToPositionAction; -import com.wix.detox.espresso.action.DetoxMultiTap; +import com.wix.detox.espresso.action.DetoxCustomTapper; import com.wix.detox.espresso.action.GetAttributesAction; import com.wix.detox.espresso.action.LongPressAndDragAction; import com.wix.detox.espresso.action.RNClickAction; @@ -29,6 +29,7 @@ import com.wix.detox.espresso.action.ScrollToIndexAction; import com.wix.detox.espresso.action.TakeViewScreenshotAction; import com.wix.detox.espresso.action.common.utils.ViewInteractionExt; +import com.wix.detox.espresso.action.common.DetoxViewConfigurations; import com.wix.detox.espresso.scroll.DetoxScrollAction; import com.wix.detox.espresso.scroll.DetoxScrollActionStaleAtEdge; import com.wix.detox.espresso.scroll.ScrollEdgeException; @@ -42,7 +43,6 @@ import java.util.Calendar; import java.util.Date; - /** * Created by simonracz on 10/07/2017. */ @@ -57,24 +57,29 @@ private DetoxAction() { } public static ViewAction multiClick(int times) { - return actionWithAssertions(new GeneralClickAction(new DetoxMultiTap(times), GeneralLocation.CENTER, Press.FINGER, 0, 0)); + return actionWithAssertions(new GeneralClickAction(new DetoxCustomTapper(times), GeneralLocation.CENTER, Press.FINGER, 0, 0)); } public static ViewAction tapAtLocation(final int x, final int y) { + CoordinatesProvider coordinatesProvider = createCoordinatesProvider(x, y); + return actionWithAssertions(new RNClickAction(coordinatesProvider)); + } + + private static CoordinatesProvider createCoordinatesProvider(final int x, final int y) { final int px = DeviceDisplay.convertDpiToPx(x); final int py = DeviceDisplay.convertDpiToPx(y); - CoordinatesProvider c = new CoordinatesProvider() { - @Override - public float[] calculateCoordinates(View view) { - final int[] xy = new int[2]; - view.getLocationOnScreen(xy); - final float fx = xy[0] + px; - final float fy = xy[1] + py; - return new float[]{fx, fy}; - } - }; - return actionWithAssertions(new RNClickAction(c)); - } + + return new CoordinatesProvider() { + @Override + public float[] calculateCoordinates(View view) { + final int[] xy = new int[2]; + view.getLocationOnScreen(xy); + final float fx = xy[0] + px; + final float fy = xy[1] + py; + return new float[]{fx, fy}; + } + }; + }; /** * Scrolls to the edge of the given scrollable view. @@ -208,6 +213,25 @@ public static ViewAction longPressAndDrag(Integer duration, )); } + public static ViewAction longPress() { + return longPress(null, null, null); + } + + public static ViewAction longPress(Integer duration) { + return longPress(null, null, duration); + } + + public static ViewAction longPress(Integer x, Integer y) { + return longPress(x, y, null); + } + + public static ViewAction longPress(Integer x, Integer y, Integer duration) { + Long finalDuration = duration != null ? duration : DetoxViewConfigurations.getLongPressTimeout(); + CoordinatesProvider coordinatesProvider = x == null || y == null ? null : createCoordinatesProvider(x, y); + + return actionWithAssertions(new RNClickAction(coordinatesProvider, finalDuration)); + } + public static ViewAction takeViewScreenshot() { return new ViewActionWithResult() { private final TakeViewScreenshotAction action = new TakeViewScreenshotAction(); diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNClickAction.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNClickAction.java index 68bf039064..f9cf16eb8d 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNClickAction.java +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNClickAction.java @@ -21,21 +21,23 @@ public class RNClickAction implements ViewAction { private final GeneralClickAction clickAction; public RNClickAction() { - clickAction = new GeneralClickAction( - new DetoxSingleTap(), - GeneralLocation.VISIBLE_CENTER, - Press.FINGER, - InputDevice.SOURCE_UNKNOWN, - MotionEvent.BUTTON_PRIMARY); + this(null, null); } public RNClickAction(CoordinatesProvider coordinatesProvider) { + this(coordinatesProvider, null); + } + + public RNClickAction(CoordinatesProvider coordinatesProvider, Long duration) { + coordinatesProvider = coordinatesProvider != null ? coordinatesProvider : GeneralLocation.VISIBLE_CENTER; + clickAction = new GeneralClickAction( - new DetoxSingleTap(), - coordinatesProvider, - Press.FINGER, - InputDevice.SOURCE_UNKNOWN, - MotionEvent.BUTTON_PRIMARY); + new DetoxSingleTap(duration), + coordinatesProvider, + Press.FINGER, + InputDevice.SOURCE_UNKNOWN, + MotionEvent.BUTTON_PRIMARY + ); } @Override diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/DetoxMultiTap.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/DetoxCustomTapper.kt similarity index 94% rename from detox/android/detox/src/main/java/com/wix/detox/espresso/action/DetoxMultiTap.kt rename to detox/android/detox/src/main/java/com/wix/detox/espresso/action/DetoxCustomTapper.kt index 79c976f815..230bec47d2 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/DetoxMultiTap.kt +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/DetoxCustomTapper.kt @@ -29,7 +29,7 @@ import com.wix.detox.espresso.action.common.TapEvents * * This should be Espresso's default implementation IMO. */ -open class DetoxMultiTap +open class DetoxCustomTapper @JvmOverloads constructor( private val times: Int, private val interTapsDelayMs: Long = getDoubleTapMinTime(), @@ -37,8 +37,9 @@ open class DetoxMultiTap private val longTapMinTimeMs: Long = getLongTapMinTime(), private val tapEvents: TapEvents = TapEvents(), private val uiControllerCallSpy: UiControllerSpy = UiControllerSpy.instance, - private val log: DetoxLog = DetoxLog.instance) - : Tapper { + private val log: DetoxLog = DetoxLog.instance, + private val duration: Long? = null + ) : Tapper { override fun sendTap(uiController: UiController?, coordinates: FloatArray?, precision: FloatArray?) = sendTap(uiController, coordinates, precision, 0, 0) @@ -69,11 +70,12 @@ open class DetoxMultiTap var downTimestamp: Long? = null for (i in 1..times) { - val tapEvents = tapEvents.createEventsSeq(coordinates, precision, downTimestamp) + val tapEvents = tapEvents.createEventsSeq(coordinates, precision, downTimestamp, duration) eventSequence.addAll(tapEvents) downTimestamp = tapEvents.last().eventTime + interTapsDelayMs } + return eventSequence } @@ -104,8 +106,8 @@ open class DetoxMultiTap private fun verifyTapEventTimes(upEvent: CallInfo, downEvent: CallInfo) { val delta: Long = (upEvent - downEvent)!! - if (delta >= longTapMinTimeMs) { + if (delta >= longTapMinTimeMs && duration == null) { log.warn(LOG_TAG, "Tap handled too slowly, and turned into a long-tap!") // TODO conditionally turn into an error, based on a global strict-mode detox config } } -} \ No newline at end of file +} diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/DetoxSingleTap.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/DetoxSingleTap.kt index 7ca0f14e24..2e848ee28e 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/DetoxSingleTap.kt +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/DetoxSingleTap.kt @@ -1,3 +1,3 @@ package com.wix.detox.espresso.action -open class DetoxSingleTap : DetoxMultiTap(1) \ No newline at end of file +class DetoxSingleTap(duration: Long? = null) : DetoxCustomTapper(1, duration = duration) diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/common/DetoxViewConfiguration.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/common/DetoxViewConfiguration.kt index 3f2d0fc13c..0125a50ffe 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/common/DetoxViewConfiguration.kt +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/common/DetoxViewConfiguration.kt @@ -9,6 +9,16 @@ private const val LOG_TAG = "Detox-ViewConfig" object DetoxViewConfigurations { + /** + * Duration before a press turns into a long press. + * Due to `Tap.LONG`, factor 1.5 is needed, otherwise a long press is not safely detected. + * + * @see androidx.test.espresso.action.Tap.LONG + * @see android.test.TouchUtils.longClickView + */ + @JvmStatic + fun getLongPressTimeout(): Long = (ViewConfiguration.getLongPressTimeout() * 1.5).toLong() + fun getPostTapCoolDownTime() = ViewConfiguration.getDoubleTapTimeout().toLong() /** diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/common/TapEvents.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/common/TapEvents.kt index d08db638a7..daf01822d7 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/common/TapEvents.kt +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/common/TapEvents.kt @@ -15,16 +15,24 @@ import android.view.MotionEvent * Lastly, With the case of _that_ specific bug, we implicitly indirectly work around it with this approach, because we highly increase * the chance of allowing a frame to be drawn in between the _down_ and _up_ events. */ -private const val EVENTS_TIME_GAP_MS = 30 +private const val EVENTS_TIME_GAP_MS = 30L class TapEvents(private val motionEvents: MotionEvents = MotionEvents()) { fun createEventsSeq(coordinates: FloatArray, precision: FloatArray) - = createEventsSeq(coordinates, precision, null) + = createEventsSeq(coordinates, precision, null, null) - fun createEventsSeq(coordinates: FloatArray, precision: FloatArray, downTimestamp: Long?): List { + fun createEventsSeq( + coordinates: FloatArray, + precision: FloatArray, + downTimestamp: Long?, + duration: Long? + ): List { val (x, y) = coordinates val downEvent = motionEvents.obtainDownEvent(x, y, precision, downTimestamp) - val upEvent = motionEvents.obtainUpEvent(downEvent, downEvent.eventTime + EVENTS_TIME_GAP_MS, x, y) + + val upEventDuration = duration ?: EVENTS_TIME_GAP_MS + val upEvent = motionEvents.obtainUpEvent(downEvent, downEvent.eventTime + upEventDuration, x, y) + return arrayListOf(downEvent, upEvent) } } diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/common/TapEventsSpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/common/TapEventsSpec.kt index e0955fa2ec..43aecdc183 100644 --- a/detox/android/detox/src/testFull/java/com/wix/detox/common/TapEventsSpec.kt +++ b/detox/android/detox/src/testFull/java/com/wix/detox/common/TapEventsSpec.kt @@ -90,16 +90,28 @@ object TapEventsSpec: Spek({ val precision = fancyPrecision() val downTimestamp = 10203040L - uut().createEventsSeq(coordinates, precision, downTimestamp) + uut().createEventsSeq(coordinates, precision, downTimestamp, null) verifyDownEventObtainedWithDownTimestamp(coordinates, precision, downTimestamp) } - it("should allow for down-time to be null") { + it("should allow for duration to be set") { + val duration = 1000L + val expectedUpEventTime = DEFAULT_EVENT_TIME + duration val coordinates = dontCareCoordinates() val precision = dontCarePrecision() - uut().createEventsSeq(coordinates, precision, downTimestamp = null as Long?) + uut().createEventsSeq(coordinates, precision, null, duration) + + verifyDownEventObtainedWithDownTimestamp(coordinates, precision, null) + verifyUpEventObtainedWithTimestamp(expectedUpEventTime) + } + + it("should allow for down-time and duration to be null") { + val coordinates = dontCareCoordinates() + val precision = dontCarePrecision() + + uut().createEventsSeq(coordinates, precision, null, null) verifyDownEventObtainedWithDownTimestamp(coordinates, precision, null) } diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/DetoxMultiTapSpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/DetoxCustomTapperSpec.kt similarity index 83% rename from detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/DetoxMultiTapSpec.kt rename to detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/DetoxCustomTapperSpec.kt index a5601294dd..4b1b07c79f 100644 --- a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/DetoxMultiTapSpec.kt +++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/DetoxCustomTapperSpec.kt @@ -14,8 +14,8 @@ import org.spekframework.spek2.style.specification.describe import java.lang.NullPointerException import kotlin.test.assertFailsWith -object DetoxMultiTapSpec: Spek({ - describe("Detox multi-tapper replacement for Espresso") { +object DetoxCustomTapperSpec: Spek({ + describe("Detox custom-tapper replacement for Espresso") { val coolDownTimeMs = 111L val interTapsDelayMs = 667L @@ -41,8 +41,10 @@ object DetoxMultiTapSpec: Spek({ mock1stTapEventsSeq = arrayListOf(downEvent, upEvent) mock2ndTapEventsSeq = arrayListOf(mock(name = "mockSeq2Event1"), mock(name = "mockSeq2Event2")) tapEvents = mock { - on { createEventsSeq(any(), any(), isNull()) }.doReturn(mock1stTapEventsSeq) - on { createEventsSeq(any(), any(), any()) }.doReturn(mock2ndTapEventsSeq) + on { createEventsSeq(any(), any(), isNull(), isNull()) }.doReturn(mock1stTapEventsSeq) + on { createEventsSeq(any(), any(), isNull(), any()) }.doReturn(mock1stTapEventsSeq) + on { createEventsSeq(any(), any(), any(), isNull()) }.doReturn(mock2ndTapEventsSeq) + on { createEventsSeq(any(), any(), any(), any()) }.doReturn(mock2ndTapEventsSeq) } uiControllerCallSpy = mock() { @@ -52,9 +54,10 @@ object DetoxMultiTapSpec: Spek({ log = mock() } - fun verify1stTapEventsSeqGenerated() = verify(tapEvents).createEventsSeq(coordinates, precision, null) - fun verify2ndTapEventsSeqGenerated() = verify(tapEvents).createEventsSeq(eq(coordinates), eq(precision), any()) - fun verify2ndTapEventsGenerateWithTimestamp(downTimestamp: Long) = verify(tapEvents).createEventsSeq(any(), any(), eq(downTimestamp)) + fun verify1stTapEventsSeqGenerated(duration: Long? = null) = verify(tapEvents).createEventsSeq(eq(coordinates), eq(precision), isNull(), eq(duration)) + fun verify2ndTapEventsSeqGenerated() = verify(tapEvents).createEventsSeq(eq(coordinates), eq(precision), isNull(), isNull()) + fun verify2ndTapEventsGenerateWithTimestamp(downTimestamp: Long) = verify(tapEvents).createEventsSeq(eq(coordinates), eq(precision), eq(downTimestamp), isNull()) + fun verifyAllTapEventsInjected() = verify(uiController).injectMotionEventSequence(arrayListOf(mock1stTapEventsSeq, mock2ndTapEventsSeq).flatten()) fun verifyMainThreadSynced() = verify(uiController).loopMainThreadForAtLeast(eq(coolDownTimeMs)) fun verifyMainThreadNeverSynced() = verify(uiController, never()).loopMainThreadForAtLeast(any()) @@ -64,17 +67,24 @@ object DetoxMultiTapSpec: Spek({ fun givenInjectionError() = whenever(uiController.injectMotionEventSequence(any())).doThrow(RuntimeException("exceptionMock")) fun givenInjectionCallsHistory(injectionsHistory: List) = - whenever(uiControllerCallSpy.eventInjectionsIterator()).thenReturn(injectionsHistory.iterator()) + whenever(uiControllerCallSpy.eventInjectionsIterator()).thenReturn(injectionsHistory.iterator()) + + fun uut(times: Int, duration: Long? = null) = + DetoxCustomTapper(times, interTapsDelayMs, coolDownTimeMs, longTapMinTimeMs, tapEvents, uiControllerCallSpy, log, duration) - fun uut(times: Int) = DetoxMultiTap(times, interTapsDelayMs, coolDownTimeMs, longTapMinTimeMs, tapEvents, uiControllerCallSpy, log) - fun sendOneTap(uut: DetoxMultiTap = uut(1)) = uut.sendTap(uiController, coordinates, precision, -1, -1) - fun sendTwoTaps(uut: DetoxMultiTap = uut(2)) = uut.sendTap(uiController, coordinates, precision, -1, -1) + fun sendOneTap(duration: Long? = null) = uut(1, duration).sendTap(uiController, coordinates, precision, -1, -1) + fun sendTwoTaps(uut: DetoxCustomTapper = uut(2)) = uut.sendTap(uiController, coordinates, precision, -1, -1) it("should generate a single-tap events sequence using tap-events helper") { sendOneTap() verify1stTapEventsSeqGenerated() } + it("should generate a single-tap events sequence with a custom duration") { + sendOneTap(1000L) + verify1stTapEventsSeqGenerated(1000L) + } + it("should generate multiple sets of single-tap event sequences using tap-events helper") { sendTwoTaps() verify1stTapEventsSeqGenerated() diff --git a/detox/detox.d.ts b/detox/detox.d.ts index 2ef5483ea2..d34da14bac 100644 --- a/detox/detox.d.ts +++ b/detox/detox.d.ts @@ -1306,7 +1306,7 @@ declare global { interface NativeElementActions extends NativeElementWaitableActions{ /** * Simulate tap on an element - * @param point relative coordinates to the matched element (the element size could changes on different devices or even when changing the device font size) + * @param point coordinates in the element's coordinate space. Optional (default is the center of the element). * @example await element(by.id('tappable')).tap(); * @example await element(by.id('tappable')).tap({ x:5, y:10 }); */ @@ -1314,10 +1314,19 @@ declare global { /** * Simulate long press on an element - * @param duration (iOS only) custom press duration time, in milliseconds. Optional (default is 1000ms). + * @param point coordinates in the element's coordinate space. Optional (default is the center of the element). + * @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 element(by.id('tappable')).longPress(); + * @example await element(by.id('tappable')).longPress(2000); + * @example await element(by.id('tappable')).longPress({ x:5, y:10 }); + * @example await element(by.id('tappable')).longPress({ x:5, y:10 }, 1500); */ - longPress(duration?: number): Promise; + longPress(): Promise; + longPress(point: Point2D): Promise; + longPress(duration: number): Promise; + longPress(point: Point2D, duration: number): Promise; /** * Simulate long press on an element and then drag it to the position of the target element. (iOS Only) @@ -1335,7 +1344,7 @@ declare global { /** * Simulate tap at a specific point on an element. - * Note: The point coordinates are relative to the matched element and the element size could changes on different devices or even when changing the device font size. + * Note: The point coordinates are relative to the matched element and the element size could change on different devices or even when changing the device font size. * @example await element(by.id('tappable')).tapAtPoint({ x:5, y:10 }); * @deprecated Use `.tap()` instead. */ diff --git a/detox/ios/Detox/Invocation/Action.swift b/detox/ios/Detox/Invocation/Action.swift index 1a4470d741..a26d6f0df4 100644 --- a/detox/ios/Detox/Invocation/Action.swift +++ b/detox/ios/Detox/Invocation/Action.swift @@ -197,61 +197,86 @@ class LongPressAction : Action { } override func perform(on element: Element) -> [String: Any]? { - let duration : TimeInterval - if let param = params?.first as? Double { - duration = param.toSeconds() + if targetElement != nil { + return performLongPressAndDrag(on: element) } else { - duration = 1.0 + return performLongPress(on: element) } - - guard let parameters = params, parameters.count > 1 else { - // Regular long press - element.longPress(duration: duration) - return nil + } + + private func performLongPress(on element: Element) -> [String: Any]? { + var duration: TimeInterval = 1.0 + var point: CGPoint? + + if let params { + for param in params { + if let number = param as? Double { + duration = number.toSeconds() + } else if let pointDict = param as? [String: CGFloat], let x = pointDict["x"], let y = pointDict["y"] { + point = CGPoint(x: x, y: y) + } + } } - - guard let targetElement = self.targetElement else { - fatalError("Target element is missing") + + element.longPress(at: point, duration: duration) + return nil + } + + private func performLongPressAndDrag(on element: Element) -> [String: Any]? { + guard let targetElement, let params else { + fatalError("Invalid params") } - - guard parameters.count > 2 else { + + guard params.count > 0, let duration = (params[0] as? Double)?.toSeconds() else { + fatalError("Unknown duration") + } + + guard params.count > 2 else { fatalError("Unknown normalized starting point") } - - let normalizedStartingPoint = getNormalizedPoint(xPosition: parameters[1], yPosition: parameters[2]) - - guard parameters.count > 4 else { + + let normalizedStartingPoint = getNormalizedPoint(xPosition: params[1], yPosition: params[2]) + + guard params.count > 4 else { fatalError("Unknown normalized target point") } - - let normalizedTargetingPoint = getNormalizedPoint(xPosition: parameters[3], yPosition: parameters[4]) - + + let normalizedTargetingPoint = getNormalizedPoint(xPosition: params[3], yPosition: params[4]) + var speed = CGFloat(0.5) - if let speedString = parameters[5] as? String { + if let speedString = params[5] as? String { switch speedString { - case "slow": - speed = 0.1 - break; - case "fast": - speed = 0.5 - break - default: - fatalError("Unknown speed") + case "slow": + speed = 0.1 + break; + case "fast": + speed = 0.5 + break + default: + fatalError("Unknown speed") } } - + let endDuration : TimeInterval - if let param = parameters[6] as? Double { + + if let param = params[6] as? Double { endDuration = param.toSeconds() } else { endDuration = 1.0 } - - element.longPress(at: normalizedStartingPoint, duration: duration, dragToElement: targetElement, normalizedTargetPoint: normalizedTargetingPoint, velocity: speed, holdForDuration: endDuration) - + + element.longPress( + at: normalizedStartingPoint, + duration: duration, + dragToElement: targetElement, + normalizedTargetPoint: normalizedTargetingPoint, + velocity: speed, + holdForDuration: endDuration + ) + return nil } - + func getNormalizedPoint(xPosition: Any, yPosition: Any) -> CGPoint { let xPos, yPos: Double diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js index add31b1218..059d27d276 100644 --- a/detox/src/android/AndroidExpect.test.js +++ b/detox/src/android/AndroidExpect.test.js @@ -213,6 +213,9 @@ describe('AndroidExpect', () => { await e.element(e.by.label('Tap Me')).tap({ x: 10, y: 10 }); await e.element(e.by.label('Tap Me')).tapAtPoint({ x: 100, y: 200 }); await e.element(e.by.label('Tap Me')).longPress(); + await e.element(e.by.label('Tap Me')).longPress(1000); + await e.element(e.by.label('Tap Me')).longPress({ x: 10, y: 10 }, 1000); + await e.element(e.by.label('Tap Me')).longPress({ x: 10, y: 10 }); await e.element(e.by.id('UniqueId819')).multiTap(3); }); @@ -220,6 +223,11 @@ describe('AndroidExpect', () => { await [null, undefined, 0, -1, 'NaN'].forEach(item => { jestExpect(() => e.element(e.by.id('UniqueId819')).multiTap(item)).rejects.toThrow(); }); + + await jestExpect(() => e.element(e.by.label('Tap Me')).longPress('NaN')).rejects.toThrow(); + await jestExpect(() => e.element(e.by.label('Tap Me')).longPress('NaN', 1000)).rejects.toThrow(); + await jestExpect(() => e.element(e.by.label('Tap Me')).longPress({ x: 'NaN', y: 10 }, 1000)).rejects.toThrow(); + await jestExpect(() => e.element(e.by.label('Tap Me')).longPress({ x: 10, y: 'NaN' }, 1000)).rejects.toThrow(); }); it('should press special keys', async () => { diff --git a/detox/src/android/actions/native.js b/detox/src/android/actions/native.js index 61b96ec695..63658a31cd 100644 --- a/detox/src/android/actions/native.js +++ b/detox/src/android/actions/native.js @@ -25,9 +25,11 @@ class TapAtPointAction extends Action { } class LongPressAction extends Action { - constructor() { + constructor(point, duration) { super(); - this._call = invoke.callDirectly(ViewActionsApi.longClick()); + + const filteredArgs = (point ? [point.x, point.y] : []).concat(duration ? [duration] : []); + this._call = invoke.callDirectly(DetoxActionApi.longPress(...filteredArgs)); } } diff --git a/detox/src/android/core/NativeElement.js b/detox/src/android/core/NativeElement.js index e9a73c8587..ae39d6512a 100644 --- a/detox/src/android/core/NativeElement.js +++ b/detox/src/android/core/NativeElement.js @@ -7,6 +7,7 @@ const DetoxRuntimeError = require('../../errors/DetoxRuntimeError'); const invoke = require('../../invoke'); const { removeMilliseconds } = require('../../utils/dateUtils'); const { actionDescription } = require('../../utils/invocationTraceDescriptions'); +const mapLongPressArguments = require('../../utils/mapLongPressArguments'); const actions = require('../actions/native'); const DetoxMatcherApi = require('../espressoapi/DetoxMatcher'); const { ActionInteraction } = require('../interactions/native'); @@ -42,9 +43,11 @@ class NativeElement { return await new ActionInteraction(this._invocationManager, this._matcher, action, traceDescription).execute(); } - async longPress() { - const action = new actions.LongPressAction(); - const traceDescription = actionDescription.longPress(); + async longPress(optionalPointOrDuration, optionalDuration) { + const { point, duration } = mapLongPressArguments(optionalPointOrDuration, optionalDuration); + + const action = new actions.LongPressAction(point, duration); + const traceDescription = actionDescription.longPress(point, duration); return await new ActionInteraction(this._invocationManager, this._matcher, action, traceDescription).execute(); } diff --git a/detox/src/android/espressoapi/DetoxAction.js b/detox/src/android/espressoapi/DetoxAction.js index ee600acda2..bf19642281 100644 --- a/detox/src/android/espressoapi/DetoxAction.js +++ b/detox/src/android/espressoapi/DetoxAction.js @@ -68,6 +68,25 @@ class DetoxAction { }; } + static createCoordinatesProvider(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.DetoxAction" + }, + method: "createCoordinatesProvider", + args: [{ + type: "Integer", + value: x + }, { + type: "Integer", + value: y + }] + }; + } + static scrollToEdge(edge, startOffsetPercentX, startOffsetPercentY) { if (typeof edge !== "string") throw new Error("edge should be a string, but got " + (edge + (" (" + (typeof edge + ")")))); if (typeof startOffsetPercentX !== "number") throw new Error("startOffsetPercentX should be a number, but got " + (startOffsetPercentX + (" (" + (typeof startOffsetPercentX + ")")))); @@ -271,6 +290,92 @@ class DetoxAction { }; } + static longPress() { + function longPress0() { + return { + target: { + type: "Class", + value: "com.wix.detox.espresso.DetoxAction" + }, + method: "longPress", + args: [] + }; + } + + function longPress1(duration) { + 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.DetoxAction" + }, + method: "longPress", + args: [{ + type: "Integer", + value: duration + }] + }; + } + + 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.DetoxAction" + }, + 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.DetoxAction" + }, + method: "longPress", + args: [{ + type: "Integer", + value: x + }, { + type: "Integer", + value: y + }, { + type: "Integer", + value: duration + }] + }; + } + + if (arguments.length === 0) { + return longPress0.apply(null, arguments); + } + + if (arguments.length === 1) { + return longPress1.apply(null, arguments); + } + + if (arguments.length === 2) { + return longPress2.apply(null, arguments); + } + + if (arguments.length === 3) { + return longPress3.apply(null, arguments); + } + } + static takeViewScreenshot() { return { target: { diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js index c89b497ba9..e1ced99c3e 100644 --- a/detox/src/ios/expectTwo.js +++ b/detox/src/ios/expectTwo.js @@ -12,6 +12,7 @@ const { removeMilliseconds } = require('../utils/dateUtils'); const { actionDescription, expectDescription } = require('../utils/invocationTraceDescriptions'); const { isRegExp } = require('../utils/isRegExp'); const log = require('../utils/logger').child({ cat: 'ws-client, ws' }); +const mapLongPressArguments = require('../utils/mapLongPressArguments'); const traceInvocationCall = require('../utils/traceInvocationCall').bind(null, log); const { webElement, webMatcher, webExpect, isWebElement } = require('./web'); @@ -155,21 +156,17 @@ class Element { } tap(point) { - if (point) { - if (typeof point !== 'object') throw new Error('point should be a object, but got ' + (point + (' (' + (typeof point + ')')))); - if (typeof point.x !== 'number') throw new Error('point.x should be a number, but got ' + (point.x + (' (' + (typeof point.x + ')')))); - if (typeof point.y !== 'number') throw new Error('point.y should be a number, but got ' + (point.y + (' (' + (typeof point.y + ')')))); - } + _assertValidPoint(point); const traceDescription = actionDescription.tapAtPoint(point); return this.withAction('tap', traceDescription, point); } - longPress(duration = 1000) { - if (typeof duration !== 'number') throw new Error('duration should be a number, but got ' + (duration + (' (' + (typeof duration + ')')))); + longPress(arg1, arg2) { + let { point, duration } = mapLongPressArguments(arg1, arg2); - const traceDescription = actionDescription.longPress(duration); - return this.withAction('longPress', traceDescription, duration); + const traceDescription = actionDescription.longPress(point, duration); + return this.withAction('longPress', traceDescription, point, duration); } longPressAndDrag(duration, normalizedPositionX, normalizedPositionY, targetElement, @@ -595,9 +592,12 @@ class WaitFor { return this.waitForWithAction(traceDescription); } - longPress(duration) { - this.action = this.actionableElement.longPress(duration); - const traceDescription = actionDescription.longPress(duration); + longPress(arg1, arg2) { + this.action = this.actionableElement.longPress(arg1, arg2); + + let { point, duration } = mapLongPressArguments(arg1, arg2); + const traceDescription = actionDescription.longPress(point, duration); + return this.waitForWithAction(traceDescription); } @@ -800,6 +800,17 @@ class IosExpect { } } +function _assertValidPoint(point) { + if (!point) { + // point is optional + return; + } + + if (typeof point !== 'object') throw new Error('point should be a object, but got ' + (point + (' (' + (typeof point + ')')))); + if (typeof point.x !== 'number') throw new Error('point.x should be a number, but got ' + (point.x + (' (' + (typeof point.x + ')')))); + if (typeof point.y !== 'number') throw new Error('point.y should be a number, but got ' + (point.y + (' (' + (typeof point.y + ')')))); +} + function throwMatcherError(param) { throw new Error(`${param} is not a Detox matcher. More about Detox matchers here: https://wix.github.io/Detox/docs/api/matchers`); } diff --git a/detox/src/ios/expectTwoApiCoverage.test.js b/detox/src/ios/expectTwoApiCoverage.test.js index f8c526d71a..10e32e053d 100644 --- a/detox/src/ios/expectTwoApiCoverage.test.js +++ b/detox/src/ios/expectTwoApiCoverage.test.js @@ -128,6 +128,8 @@ describe('expectTwo API Coverage', () => { await e.element(e.by.label('Tap Me')).tapAtPoint({ x: 10, y: 10 }); await e.element(e.by.label('Tap Me')).longPress(); await e.element(e.by.label('Tap Me')).longPress(2000); + await e.element(e.by.label('Tap Me')).longPress({ x: 10, y: 10 }, 2000); + await e.element(e.by.label('Tap Me')).longPress({ x: 10, y: 10 }); await e.element(e.by.id('someId')).multiTap(3); await e.element(e.by.id('someId')).typeText('passcode'); await e.element(e.by.id('someId')).tapBackspaceKey(); @@ -217,6 +219,12 @@ describe('expectTwo API Coverage', () => { await expectToThrow(() => e.element(e.by.id('slider')).adjustSliderToPosition(-1)); await expectToThrow(() => e.element(e.by.id('slider')).adjustSliderToPosition(NaN)); + await expectToThrow(() => e.element(e.by.id('someId')).longPress('notANumber')); + await expectToThrow(() => e.element(e.by.id('someId')).longPress(1000, 1000)); + await expectToThrow(() => e.element(e.by.id('someId')).longPress({ x: 'notANumber', y: 10 }, 1000)); + await expectToThrow(() => e.element(e.by.id('someId')).longPress(1000, { x: 10, y: 5 })); + await expectToThrow(() => e.element(e.by.id('someId')).longPress({ x: 10, y: 'notANumber' }, 1000)); + await expectToThrow(() => e.element(e.by.id('elementToDrag')).longPressAndDrag(1000, 0.5, 0.5, e.by.id('matcherNotElement'))); await expectToThrow(() => e.element(e.by.id('elementToDrag')).longPressAndDrag('notANumber', 0.5, 0.5, e.element(e.by.id('targetElement')))); await expectToThrow(() => e.element(e.by.id('elementToDrag')).longPressAndDrag(1000, 0.5, 0.5, e.element(e.by.id('targetElement')), 0.5, 0.5, 'slow', 'notANumber')); @@ -252,6 +260,9 @@ describe('expectTwo API Coverage', () => { await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout(-1)); await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement('notAMatcher')); await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).longPress('notANumber')); + await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).longPress(40, 40)); + await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).longPress(40, { x: 43, y: 'notANumber' })); + await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).longPress(40, { x: 'notANumber', y: 43 })); await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).multiTap('notANumber')); await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).tapAtPoint('notAPoint')); await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).tapAtPoint({ notx: 1, y: 3 })); @@ -271,6 +282,9 @@ describe('expectTwo API Coverage', () => { await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).tap(); await e.waitFor(e.element(e.by.id('id'))).not.toBeVisible().whileElement(e.by.id('id2')).tap(); await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).longPress(); + await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).longPress(20); + await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).longPress({ x: 15, y: 100 }, 20); + await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).longPress({ x: 15, y: 100 }); await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).multiTap(2); await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).tapAtPoint({ x: 1, y: 1 }); await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).tapBackspaceKey(); diff --git a/detox/src/utils/__snapshots__/assertArgument.test.js.snap b/detox/src/utils/__snapshots__/assertArgument.test.js.snap index 1b021c40da..1d5c300011 100644 --- a/detox/src/utils/__snapshots__/assertArgument.test.js.snap +++ b/detox/src/utils/__snapshots__/assertArgument.test.js.snap @@ -1,5 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`assertDuration should throw for "42" 1`] = `"duration should be a number, but got 42 (string)"`; + +exports[`assertDuration should throw for false 1`] = `"duration should be a number, but got false (boolean)"`; + exports[`assertEnum should create an assertion function for enums 1`] = `"speed should be one of [fast, slow], but got medium (string)"`; exports[`assertNormalized should throw for "0.5" 1`] = `"invalidNumber should be a number, but got 0.5 (string)"`; @@ -18,6 +22,20 @@ exports[`assertNumber should throw for "42" 1`] = `"invalidNumber should be a nu exports[`assertNumber should throw for false 1`] = `"invalidNumber should be a number, but got false (boolean)"`; +exports[`assertPoint should throw for {"x":"0","y":0} 1`] = `"point should be an object with x and y properties, but got {"x":"0","y":0}"`; + +exports[`assertPoint should throw for {"x":0,"y":"0"} 1`] = `"point should be an object with x and y properties, but got {"x":0,"y":"0"}"`; + +exports[`assertPoint should throw for {"x":0} 1`] = `"point should be an object with x and y properties, but got {"x":0}"`; + +exports[`assertPoint should throw for {"y":0} 1`] = `"point should be an object with x and y properties, but got {"y":0}"`; + exports[`assertString should throw for 123 1`] = `"invalidString should be a string, but got 123 (number)"`; exports[`assertString should throw for undefined 1`] = `"invalidString should be a string, but got undefined (undefined)"`; + +exports[`assertUndefined should throw for "str" 1`] = `"0 expected to be undefined, but got s (string)"`; + +exports[`assertUndefined should throw for {"key":"val"} 1`] = `"key expected to be undefined, but got val (string)"`; + +exports[`assertUndefined should throw for 1 1`] = `"undefined is not iterable (cannot read property Symbol(Symbol.iterator))"`; diff --git a/detox/src/utils/assertArgument.js b/detox/src/utils/assertArgument.js index 8fadcca916..9a618d264a 100644 --- a/detox/src/utils/assertArgument.js +++ b/detox/src/utils/assertArgument.js @@ -36,9 +36,37 @@ function assertEnum(allowedValues) { }; } +function assertDuration(duration) { + if (typeof duration === 'number') { + return true; + } + + throw new DetoxRuntimeError('duration should be a number, but got ' + (duration + (' (' + (typeof duration + ')')))); +} + +function assertPoint(point) { + if (typeof point === 'object' && typeof point.x === 'number' && typeof point.y === 'number') { + return true; + } + + throw new DetoxRuntimeError(`point should be an object with x and y properties, but got ${JSON.stringify(point)}`); +} + +function assertUndefined(arg) { + if (arg === undefined) { + return true; + } + + const [key, value] = firstEntry(arg); + throw new DetoxRuntimeError(`${key} expected to be undefined, but got ${value} (${typeof value})`); +} + module.exports = { assertEnum, assertNormalized, assertNumber, assertString, + assertDuration, + assertPoint, + assertUndefined }; diff --git a/detox/src/utils/assertArgument.test.js b/detox/src/utils/assertArgument.test.js index d799f0435d..749312c838 100644 --- a/detox/src/utils/assertArgument.test.js +++ b/detox/src/utils/assertArgument.test.js @@ -1,4 +1,5 @@ const assertions = require('./assertArgument'); +const { assertUndefined } = require('./assertArgument'); describe('assertEnum', () => { const { assertEnum } = assertions; @@ -73,3 +74,54 @@ describe('assertString', () => { expect(() => assertString({ invalidString })).toThrowErrorMatchingSnapshot(); }); }); + +describe('assertDuration', () => { + const { assertDuration } = assertions; + + it.each([ + 42, + NaN, + Infinity, + -Infinity, + ])('should pass for %d', (validNumber) => { + expect(() => assertDuration(validNumber)).not.toThrow(); + }); + + it.each([ + '42', + false, + ])('should throw for %j', (invalidNumber) => { + expect(() => assertDuration(invalidNumber)).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('assertPoint', () => { + const { assertPoint } = assertions; + + it('should pass for valid point', () => { + expect(() => assertPoint({ x: 0, y: 0 })).not.toThrow(); + }); + + it.each([ + { x: 0 }, + { y: 0 }, + { x: '0', y: 0 }, + { x: 0, y: '0' }, + ])('should throw for %j', (invalidPoint) => { + expect(() => assertPoint(invalidPoint)).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('assertUndefined', () => { + it('should pass for undefined', () => { + expect(() => assertUndefined(undefined)).not.toThrow(); + }); + + it.each([ + 'str', + 1, + { key: 'val' } + ])('should throw for %j', (definedValue) => { + expect(() => assertUndefined(definedValue)).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/detox/src/utils/invocationTraceDescriptions.js b/detox/src/utils/invocationTraceDescriptions.js index bf9e4302fe..475ad14c39 100644 --- a/detox/src/utils/invocationTraceDescriptions.js +++ b/detox/src/utils/invocationTraceDescriptions.js @@ -3,7 +3,7 @@ module.exports = { adjustSliderToPosition: (newPosition) => `adjust slider to position ${newPosition}`, clearText: () => 'clear input text', getAttributes: () => 'get element attributes', - longPress: (duration) => `long press${duration !== undefined ? ` for ${duration}ms` : ''}`, + longPress: (point, duration) => `long press${duration !== null ? ` for ${duration}ms` : ''}${point !== null ? ` at ${JSON.stringify(point)}` : ''}`, longPressAndDrag: (duration, startX, startY, targetElement, endX, endY, speed, holdDuration) => `long press and drag from ${startX}, ${startY} to ${endX}, ${endY} with speed ${speed} and hold duration ${holdDuration}`, multiTap: (times) => `tap ${times} times`, diff --git a/detox/src/utils/mapLongPressArguments.js b/detox/src/utils/mapLongPressArguments.js new file mode 100644 index 0000000000..819571c99c --- /dev/null +++ b/detox/src/utils/mapLongPressArguments.js @@ -0,0 +1,32 @@ +const { DetoxRuntimeError } = require('../errors'); + +const { assertPoint, assertDuration, assertUndefined } = require('./assertArgument'); + +function mapLongPressArguments(optionalPointOrDuration, optionalDuration) { + let point = null; + let duration = null; + + try { + if (optionalPointOrDuration === undefined) { + // Do nothing. + } else if (typeof optionalPointOrDuration === 'number') { + duration = optionalPointOrDuration; + assertUndefined(optionalDuration); + } else { + assertPoint(optionalPointOrDuration); + point = optionalPointOrDuration; + + if (optionalDuration !== undefined) { + assertDuration(optionalDuration); + duration = optionalDuration; + } + } + } catch (e) { + throw new DetoxRuntimeError(`longPress accepts either a duration (number) or a point ({x: number, y: number}) as ` + + `its first argument, and optionally a duration (number) as its second argument. Error: ${e.message}`); + } + + return { point, duration }; +} + +module.exports = mapLongPressArguments; diff --git a/detox/src/utils/mapLongPressArguments.test.js b/detox/src/utils/mapLongPressArguments.test.js new file mode 100644 index 0000000000..7706c536ad --- /dev/null +++ b/detox/src/utils/mapLongPressArguments.test.js @@ -0,0 +1,26 @@ +const mapLongPressArguments = require('./mapLongPressArguments'); +describe('mapLongPressArguments', () => { + it('should return `{ point: { x: 1, y: 2 }, duration: 3 }` for `{ x: 1, y: 2 }, 3`', () => { + expect(mapLongPressArguments({ x: 1, y: 2 }, 3)).toEqual({ point: { x: 1, y: 2 }, duration: 3 }); + }); + + it('should return `{ point: { x: 1, y: 2 }, duration: null }` for `{ x: 1, y: 2 }`', () => { + expect(mapLongPressArguments({ x: 1, y: 2 })).toEqual({ point: { x: 1, y: 2 }, duration: null }); + }); + + it('should return `{ point: null, duration: 3 }` for `3`', () => { + expect(mapLongPressArguments(3)).toEqual({ point: null, duration: 3 }); + }); + + it('should return `{ point: null, duration: null }` for no arguments', () => { + expect(mapLongPressArguments()).toEqual({ point: null, duration: null }); + }); + + it('should throw for invalid point', () => { + expect(() => mapLongPressArguments({ x: 1 })).toThrowError('point should be an object with x and y properties, but got {"x":1}'); + }); + + it('should throw for invalid duration', () => { + expect(() => mapLongPressArguments({ x: 1, y: 2 }, '3')).toThrowError('duration should be a number, but got 3 (string)'); + }); +}); diff --git a/detox/test/e2e/03.actions.test.js b/detox/test/e2e/03.actions.test.js index 315db569f0..cbcadce9a8 100644 --- a/detox/test/e2e/03.actions.test.js +++ b/detox/test/e2e/03.actions.test.js @@ -2,6 +2,10 @@ const driver = require('./drivers/actions-driver').actionsScreenDriver; const custom = require('./utils/custom-it'); describe('Actions', () => { + beforeAll(async () => { + await device.launchApp(); + }); + beforeEach(async () => { await device.reloadReactNative(); await element(by.text('Actions')).tap(); @@ -22,6 +26,16 @@ describe('Actions', () => { await expect(element(by.text('Long Press With Duration Working!!!'))).toBeVisible(); }); + it('should long press with point', async () => { + await element(by.text('Long Press on Top Left')).longPress({ x: 5, y: 5 }); + await expect(element(by.text('Long Press on Top Left Working!!!'))).toBeVisible(); + }); + + it('should not succeed in long pressing with point outside the target area', async () => { + await element(by.text('Long Press on Top Left')).longPress({ x: 15, y: 15 }); + await expect(element(by.text('Long Press on Top Left Working!!!'))).not.toBeVisible(); + }); + it(':android: should tap on an element at point', async () => { await driver.tapsElement.tapAtPoint(); await driver.tapsElement.assertTappedOnce(); diff --git a/detox/test/e2e/drivers/actions-driver.js b/detox/test/e2e/drivers/actions-driver.js index 9f07007ed3..c2d7eb9520 100644 --- a/detox/test/e2e/drivers/actions-driver.js +++ b/detox/test/e2e/drivers/actions-driver.js @@ -6,7 +6,7 @@ const driver = { get coordinates() { return { x: (device.getPlatform() === 'ios' ? 180 : 100), - y: 100, + y: 120, }; }, multiTap: () => element(by.id(driver.tapsElement.testId)).multiTap(3), diff --git a/detox/test/src/Screens/ActionsScreen.js b/detox/test/src/Screens/ActionsScreen.js index ef8957c73e..7b173aba9a 100644 --- a/detox/test/src/Screens/ActionsScreen.js +++ b/detox/test/src/Screens/ActionsScreen.js @@ -88,6 +88,16 @@ export default class ActionsScreen extends Component { Long Press Me 1.5s + { + const nativeEvent = event.nativeEvent; + return nativeEvent.locationX < 10 && nativeEvent.locationY < 10; + }} + onResponderRelease={() => this.onButtonPress('Long Press on Top Left Working')} + > + Long Press on Top Left + + Tap Me For Long Timeout diff --git a/docs/api/actions.md b/docs/api/actions.md index d1658adf6b..21577dfdbf 100644 --- a/docs/api/actions.md +++ b/docs/api/actions.md @@ -8,7 +8,7 @@ Use [expectations](expect.md) to verify element states. - [`.tap()`](#tappoint) - [`.multiTap()`](#multitaptimes) -- [`.longPress()`](#longpressduration) +- [`.longPress()`](#longpresspoint-duration) - [`.longPressAndDrag()`](#longpressanddragduration-normalizedpositionx-normalizedpositiony-targetelement-normalizedtargetpositionx-normalizedtargetpositiony-speed-holdduration) - [`.swipe()`](#swipedirection-speed-normalizedoffset-normalizedstartingpointx-normalizedstartingpointy) - [`.pinch()`](#pinchscale-speed-angle--ios-only) **iOS only** @@ -51,17 +51,27 @@ Simulates multiple taps on the element at its activation point. All taps are app await element(by.id('tappable')).multiTap(3); ``` -### `longPress(duration)` +### `longPress(point, duration)` -Simulates a long press on the element at its activation point. +Simulates a long press on the element at its activation point or at the specified point. -`duration` (iOS only) — press during time, in milliseconds. Optional (default is 1000 ms). +`point` — a point in the element’s coordinate space (optional, object with `x` and `y` numerical values, default is `null`). +`duration` — press during time, in milliseconds. Optional (defaults to the standard long-press duration for the platform). ```js await element(by.id('tappable')).longPress(); +await element(by.id('tappable')).longPress({x:5, y:10}); await element(by.id('tappable')).longPress(1500); +await element(by.id('tappable')).longPress({x:5, y:10}, 1500); ``` +:::note Important + +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. + +::: + ### `longPressAndDrag(duration, normalizedPositionX, normalizedPositionY, targetElement, normalizedTargetPositionX, normalizedTargetPositionY, speed, holdDuration)` Simulates a long press on the element and then drag it to a position of another element. diff --git a/generation/__tests__/android.js b/generation/__tests__/android.js index 81abc3655b..1bd77184eb 100644 --- a/generation/__tests__/android.js +++ b/generation/__tests__/android.js @@ -87,7 +87,10 @@ describe('Android generation', () => { describe('method overloading', () => { it('should distinguish between one and two argument call of method', () => { expect(ExampleClass.overloadable(true, 42)).toEqual({ - args: [{ type: 'boolean', value: true }, { type: 'Integer', value: 42 }], + args: [ + { type: 'boolean', value: true }, + { type: 'Integer', value: 42 } + ], method: 'overloadable', target: { type: 'Class', value: 'com.wix.detox.espresso.DetoxAction' } }); diff --git a/generation/__tests__/global-functions.js b/generation/__tests__/global-functions.js index bca5ba4d6f..74e8825790 100644 --- a/generation/__tests__/global-functions.js +++ b/generation/__tests__/global-functions.js @@ -96,7 +96,7 @@ describe('globals', () => { 'adjustable', 'allowsDirectInteraction', 'pageTurn' - ].forEach(trait => { + ].forEach((trait) => { expect(typeof globals.sanitize_uiAccessibilityTraits([trait])).toBe('number'); }); }); @@ -140,9 +140,9 @@ describe('globals', () => { }); it('should not get _call property if it is not present', () => { - const unwrappedMatcher = "I am a call"; + const unwrappedMatcher = 'I am a call'; expect(globals.sanitize_matcher(unwrappedMatcher)).toBe('I am a call'); - }) + }); }); describe('sanitize_greyElementInteraction', () => { diff --git a/generation/core/generator.js b/generation/core/generator.js index 208dbb3454..b6a1b83b99 100644 --- a/generation/core/generator.js +++ b/generation/core/generator.js @@ -81,7 +81,7 @@ module.exports = function getGenerator({ const args = json.args.map(({ name }) => t.identifier(name)); if (!json.static) { - const prefixedArgs = [ t.identifier('element') ]; + const prefixedArgs = [t.identifier('element')]; args.unshift(...prefixedArgs); json.prefixedArgs = prefixedArgs; } @@ -107,13 +107,12 @@ module.exports = function getGenerator({ const overloadFunctionExpressions = json.instances.map(({ args }) => { const params = [...(json.prefixedArgs || []), ...args]; - return t.functionDeclaration( - t.identifier(sanitizedName + params.length), - params.filter(filterBlacklistedArguments).map(({ name }) => t.identifier(name)), - createMethodBody(classJson, Object.assign({}, json, { args })) - ) - } - ); + return t.functionDeclaration( + t.identifier(sanitizedName + params.length), + params.filter(filterBlacklistedArguments).map(({ name }) => t.identifier(name)), + createMethodBody(classJson, Object.assign({}, json, { args })) + ); + }); const offset = (json.prefixedArgs || []).length; const returnStatementsForNumber = (num) => @@ -134,14 +133,16 @@ module.exports = function getGenerator({ function hasProblematicOverloading(instances) { // Check if there are same lengthed argument sets const knownLengths = []; - return instances.map(({ args }) => args.length).reduce((carry, item) => { - if (carry || knownLengths.some((l) => l === item)) { - return true; - } - - knownLengths.push(item); - return false; - }, false); + return instances + .map(({ args }) => args.length) + .reduce((carry, item) => { + if (carry || knownLengths.some((l) => l === item)) { + return true; + } + + knownLengths.push(item); + return false; + }, false); } function sanitizeArgumentType(json) { @@ -212,14 +213,13 @@ module.exports = function getGenerator({ } function createReturnStatement(classJson, json) { - const args = json.args.map( - (arg) => - shouldBeWrapped(arg) - ? t.objectExpression([ - t.objectProperty(t.identifier('type'), t.stringLiteral(addArgumentTypeSanitizer(arg))), - t.objectProperty(t.identifier('value'), addArgumentContentSanitizerCall(arg, json.name)) - ]) - : addArgumentContentSanitizerCall(arg, json.name) + const args = json.args.map((arg) => + shouldBeWrapped(arg) + ? t.objectExpression([ + t.objectProperty(t.identifier('type'), t.stringLiteral(addArgumentTypeSanitizer(arg))), + t.objectProperty(t.identifier('value'), addArgumentContentSanitizerCall(arg, json.name)) + ]) + : addArgumentContentSanitizerCall(arg, json.name) ); return t.returnStatement( @@ -247,8 +247,8 @@ module.exports = function getGenerator({ return isListOfChecks ? typeCheckCreator.map((singleCheck) => singleCheck(json, functionName)) : typeCheckCreator instanceof Function - ? typeCheckCreator(json, functionName) - : t.emptyStatement(); + ? typeCheckCreator(json, functionName) + : t.emptyStatement(); } function createLogImport(pathFragments) { @@ -267,7 +267,7 @@ module.exports = function getGenerator({ globalFunctionUsage = {}; const input = fs.readFileSync(inputFile, 'utf8'); - const json = javaMethodParser(input); + const json = javaMethodParser(input); // set default name const pathFragments = outputFile.split('/'); diff --git a/generation/utils/downloadFile.js b/generation/utils/downloadFile.js index 326c4cbcf7..3a255ef7ae 100644 --- a/generation/utils/downloadFile.js +++ b/generation/utils/downloadFile.js @@ -8,7 +8,7 @@ function dumpCertificate(url, port = 443) { const execOptions = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], - timeout: 5000, + timeout: 5000 }; let host = URL.parse(url).host; @@ -25,7 +25,7 @@ function dumpCertificate(url, port = 443) { function downloadFileSync(url) { const flags = ['--silent', '--show-error', '-L']; const execOptions = { - encoding: 'utf8', + encoding: 'utf8' }; try {