From b83a79876825321a255f64ebf83c26361494f468 Mon Sep 17 00:00:00 2001 From: Leo Natan Date: Tue, 2 Jul 2019 14:17:18 +0300 Subject: [PATCH] Fix several picker woes Closes #1051 Also updated docs --- detox/ios/Detox/GREYMatchers+Detox.h | 2 + detox/ios/Detox/GREYMatchers+Detox.m | 25 +++++++ detox/ios/Detox/ReactNativeSupport.m | 21 ++++++ detox/src/android/matcher.js | 3 + .../src/ios/earlgreyapi/GREYMatchers+Detox.js | 15 ++++ detox/src/ios/expect.js | 2 + detox/src/ios/matchers.js | 10 +++ detox/test/e2e/17.datePicker.test.js | 6 +- detox/test/e2e/17.picker.test.js | 11 +++ detox/test/src/Screens/DatePickerScreen.js | 5 +- detox/test/src/Screens/PickerViewScreen.js | 58 +++++++++++++++ detox/test/src/Screens/index.js | 4 +- detox/test/src/app.js | 7 +- docs/APIRef.ActionsOnElement.md | 71 ++++++++++--------- 14 files changed, 197 insertions(+), 43 deletions(-) create mode 100644 detox/test/e2e/17.picker.test.js create mode 100644 detox/test/src/Screens/PickerViewScreen.js diff --git a/detox/ios/Detox/GREYMatchers+Detox.h b/detox/ios/Detox/GREYMatchers+Detox.h index 4b18bc22ec..5d31553077 100644 --- a/detox/ios/Detox/GREYMatchers+Detox.h +++ b/detox/ios/Detox/GREYMatchers+Detox.h @@ -28,4 +28,6 @@ + (id)detoxMatcherForClass:(NSString *)aClassName; ++ (id)detoxMatcherForPickerViewChildOfMatcher:(id)matcher; + @end diff --git a/detox/ios/Detox/GREYMatchers+Detox.m b/detox/ios/Detox/GREYMatchers+Detox.m index 796f326063..5c2881806f 100644 --- a/detox/ios/Detox/GREYMatchers+Detox.m +++ b/detox/ios/Detox/GREYMatchers+Detox.m @@ -149,4 +149,29 @@ @implementation GREYMatchers (Detox) return grey_kindOfClass(klass); } ++ (id)detoxMatcherForPickerViewChildOfMatcher:(id)matcher +{ + //No RN—Life is always good. + Class RN_RCTDatePicker = NSClassFromString(@"RCTDatePicker"); + if (!RN_RCTDatePicker) + { + return matcher; + } + + //Either take picker view or the pickerview that is a child of RCTDatePicker. + return grey_anyOf(grey_allOf( + grey_kindOfClass([UIPickerView class]), + matcher, + nil), + grey_allOf(grey_kindOfClass([UIPickerView class]), + grey_ancestor( + grey_allOf( + matcher, + grey_kindOfClass(RN_RCTDatePicker), + nil) + ), + nil), + nil); +} + @end diff --git a/detox/ios/Detox/ReactNativeSupport.m b/detox/ios/Detox/ReactNativeSupport.m index 60aa195b4b..679fddcc47 100644 --- a/detox/ios/Detox/ReactNativeSupport.m +++ b/detox/ios/Detox/ReactNativeSupport.m @@ -220,6 +220,27 @@ static void __setupRNSupport() [[GREYUIThreadExecutor sharedInstance] registerIdlingResource:[WXAnimatedDisplayLinkIdlingResource new]]; } + + //🤦‍♂️ RN doesn't set the data source and relies on undocumented behavior. + cls = NSClassFromString(@"RCTPicker"); + if(cls != nil) + { + SEL sel = @selector(initWithFrame:); + Method m = class_getInstanceMethod(cls, sel); + + if(m == nil) + { + return; + } + + id (*orig)(id, SEL, CGRect) = (void*)method_getImplementation(m); + method_setImplementation(m, imp_implementationWithBlock(^ (UIPickerView* _self, CGRect frame) { + _self = orig(_self, sel, frame); + _self.dataSource = _self; + + return _self; + })); + } } @implementation ReactNativeSupport diff --git a/detox/src/android/matcher.js b/detox/src/android/matcher.js index 021353208c..54cf6e301d 100644 --- a/detox/src/android/matcher.js +++ b/detox/src/android/matcher.js @@ -38,6 +38,9 @@ class Matcher { return this; } + _extendPickerViewMatching() { + return this; + } } class LabelMatcher extends Matcher { diff --git a/detox/src/ios/earlgreyapi/GREYMatchers+Detox.js b/detox/src/ios/earlgreyapi/GREYMatchers+Detox.js index c16692efab..3048f72931 100644 --- a/detox/src/ios/earlgreyapi/GREYMatchers+Detox.js +++ b/detox/src/ios/earlgreyapi/GREYMatchers+Detox.js @@ -154,6 +154,21 @@ class GREYMatchers { }; } + static detoxMatcherForPickerViewChildOfMatcher(matcher) { + if (typeof matcher !== "object" || matcher.type !== "Invocation" || typeof matcher.value !== "object" || typeof matcher.value.target !== "object" || matcher.value.target.value !== "GREYMatchers") { + throw new Error('matcher should be a GREYMatcher, but got ' + JSON.stringify(matcher)); + } + + return { + target: { + type: "Class", + value: "GREYMatchers" + }, + method: "detoxMatcherForPickerViewChildOfMatcher:", + args: [matcher] + }; + } + } module.exports = GREYMatchers; \ No newline at end of file diff --git a/detox/src/ios/expect.js b/detox/src/ios/expect.js index 8375d8b823..40f7476655 100644 --- a/detox/src/ios/expect.js +++ b/detox/src/ios/expect.js @@ -339,6 +339,8 @@ class Element { return await new ActionInteraction(this._invocationManager, this, new SwipeAction(direction, speed, percentage)).execute(); } async setColumnToValue(column,value) { + // override the user's element selection with an extended matcher that supports RN's date picker + this._selectElementWithMatcher(this._originalMatcher._extendPickerViewMatching()); return await new ActionInteraction(this._invocationManager, this, new ScrollColumnToValue(column, value)).execute(); } async setDatePickerDate(dateString, dateFormat) { diff --git a/detox/src/ios/matchers.js b/detox/src/ios/matchers.js index aefc203176..703bdc101a 100644 --- a/detox/src/ios/matchers.js +++ b/detox/src/ios/matchers.js @@ -33,6 +33,16 @@ class Matcher { this._call = invoke.callDirectly(GreyMatchersDetox.detoxMatcherForScrollChildOfMatcher(_originalMatcherCall)); return this; } + _extendToDescendantScrollViews() { + const _originalMatcherCall = this._call; + this._call = invoke.callDirectly(GreyMatchersDetox.detoxMatcherForScrollChildOfMatcher(_originalMatcherCall)); + return this; + } + _extendPickerViewMatching() { + const _originalMatcherCall = this._call; + this._call = invoke.callDirectly(GreyMatchersDetox.detoxMatcherForPickerViewChildOfMatcher(_originalMatcherCall)); + return this; + } } class LabelMatcher extends Matcher { diff --git a/detox/test/e2e/17.datePicker.test.js b/detox/test/e2e/17.datePicker.test.js index 68c680bd95..597ac87ecd 100644 --- a/detox/test/e2e/17.datePicker.test.js +++ b/detox/test/e2e/17.datePicker.test.js @@ -5,13 +5,13 @@ describe(':ios: DatePicker', () => { }); it('datePicker should trigger change handler correctly', async () => { - await element(by.type('UIPickerView')).setColumnToValue(1, "6"); - await element(by.type('UIPickerView')).setColumnToValue(2, "34"); + await element(by.id('datePicker')).setColumnToValue(1, "6"); + await element(by.id('datePicker')).setColumnToValue(2, "34"); await expect(element(by.id('localTimeLabel'))).toHaveText('Time: 06:34'); }); it('can select dates on a UIDatePicker', async () => { - await element(by.type('UIDatePicker')).setDatePickerDate('2019-02-06T05:10:00-08:00', "yyyy-MM-dd'T'HH:mm:ssZZZZZ"); + await element(by.id('datePicker')).setDatePickerDate('2019-02-06T05:10:00-08:00', "yyyy-MM-dd'T'HH:mm:ssZZZZZ"); await expect(element(by.id('utcDateLabel'))).toHaveText('Date (UTC): Feb 6th, 2019'); await expect(element(by.id('utcTimeLabel'))).toHaveText('Time (UTC): 1:10 PM'); }); diff --git a/detox/test/e2e/17.picker.test.js b/detox/test/e2e/17.picker.test.js new file mode 100644 index 0000000000..14e0f3a852 --- /dev/null +++ b/detox/test/e2e/17.picker.test.js @@ -0,0 +1,11 @@ +describe(":ios: Picker", () => { + beforeEach(async () => { + await device.reloadReactNative(); + await element(by.text("Picker")).tap(); + }); + + it("picker should select value correctly", async () => { + await element(by.id("pickerView")).setColumnToValue(0, "c"); + await expect(element(by.id("valueLabel"))).toHaveText("com.wix.detox.c"); + }); +}); diff --git a/detox/test/src/Screens/DatePickerScreen.js b/detox/test/src/Screens/DatePickerScreen.js index 96e917761c..829888c436 100644 --- a/detox/test/src/Screens/DatePickerScreen.js +++ b/detox/test/src/Screens/DatePickerScreen.js @@ -43,7 +43,7 @@ export default class DatePickerScreen extends Component { {"Time: " + this.getTimeLocal()} - + ); } @@ -58,8 +58,7 @@ const styles = StyleSheet.create({ }, datePicker: { width:'100%', - height:200, - backgroundColor:'green' + height:200 }, dateText: { textAlign:'center' diff --git a/detox/test/src/Screens/PickerViewScreen.js b/detox/test/src/Screens/PickerViewScreen.js new file mode 100644 index 0000000000..d3431271ac --- /dev/null +++ b/detox/test/src/Screens/PickerViewScreen.js @@ -0,0 +1,58 @@ +import moment from "moment"; +import React, { Component } from "react"; +import { Text, View, StyleSheet, Picker } from "react-native"; + +export default class PickerViewScreen extends Component { + constructor(props) { + super(props); + + this.state = { + chosenValue: "com.wix.detox.a" + }; + + this.setValue = this.setValue.bind(this); + } + + setValue(newValue) { + this.setState({ + chosenValue: newValue + }); + } + + render() { + return ( + + + {this.state.chosenValue} + + + + + + + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingTop: 20, + justifyContent: "center", + alignItems: "center" + }, + datePicker: { + width:"100%", + height:200 + }, + dateText: { + textAlign:"center" + } +}); diff --git a/detox/test/src/Screens/index.js b/detox/test/src/Screens/index.js index 07fc463ff5..c22c91faa2 100644 --- a/detox/test/src/Screens/index.js +++ b/detox/test/src/Screens/index.js @@ -16,6 +16,7 @@ import ShakeScreen from './ShakeScreen'; import DatePickerScreen from './DatePickerScreen'; import LanguageScreen from './LanguageScreen'; import LaunchArgsScreen from './LaunchArgsScreen'; +import PickerViewScreen from './PickerViewScreen'; export { SanityScreen, @@ -34,6 +35,7 @@ export { LocationScreen, ShakeScreen, DatePickerScreen, + PickerViewScreen, LanguageScreen, - LaunchArgsScreen, + LaunchArgsScreen }; diff --git a/detox/test/src/app.js b/detox/test/src/app.js index a5114c6e73..29ccd89628 100644 --- a/detox/test/src/app.js +++ b/detox/test/src/app.js @@ -35,6 +35,10 @@ class example extends Component { } renderScreenButton(title, component) { + if(component == null) { + throw new Error("Got no component for " + title); + } + return this.renderButton(title, () => { this.setState({screen: component}); }); @@ -100,7 +104,8 @@ class example extends Component { {this.renderScreenButton('Network', Screens.NetworkScreen)} {this.renderScreenButton('Animations', Screens.AnimationsScreen)} {this.renderScreenButton('Location', Screens.LocationScreen)} - {this.renderScreenButton('DatePicker', Screens.DatePickerScreen)} + {!isAndroid && this.renderScreenButton('DatePicker', Screens.DatePickerScreen)} + {!isAndroid && this.renderScreenButton('Picker', Screens.PickerViewScreen)} {this.renderButton('Crash', () => { throw new Error('Simulated Crash') })} diff --git a/docs/APIRef.ActionsOnElement.md b/docs/APIRef.ActionsOnElement.md index f99cc80637..83552ce0b6 100644 --- a/docs/APIRef.ActionsOnElement.md +++ b/docs/APIRef.ActionsOnElement.md @@ -26,14 +26,14 @@ Actions are functions that emulate user behavior. They are being performed on ma ### `tap()` -Simulate tap on an element. +Simulates a tap on an element. ```js await element(by.id('tappable')).tap(); ``` ### `longPress(duration)` -Simulate long press on an element. +Simulates a long press on an element. duration - long press time interval. (iOS only) ```js @@ -41,13 +41,13 @@ await element(by.id('tappable')).longPress(); ``` ### `multiTap(times)` -Simulate multiple taps on an element. +Simulates multiple taps on an element. ```js await element(by.id('tappable')).multiTap(3); ``` ### `tapAtPoint()` -Simulate tap at a specific point on an element. +Simulates a 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. @@ -56,43 +56,36 @@ await element(by.id('tappable')).tapAtPoint({x:5, y:10}); ``` ### `tapBackspaceKey()` -Tap the backspace key on the built-in keyboard. +Taps the backspace key on the built-in keyboard. ```js await element(by.id('textField')).tapBackspaceKey(); ``` ### `tapReturnKey()` -Tap the return key on the built-in keyboard. +Taps the return key on the built-in keyboard. ```js await element(by.id('textField')).tapReturnKey(); ``` ### `typeText(text)` -Use the builtin keyboard to type text into a text field. +Uses the builtin keyboard to type text into a text field. ```js await element(by.id('textField')).typeText('passcode'); ``` -> **Note:** Make sure to toggle the software keyboard on text fields. -> -> To do this, open the simulator, tap any text field in your app, then select **Hardware** -> **Keyboard** -> **Toggle Software Keyboard** (⌘K) to automatically toggle the builtin keyboard on each time a text field is tapped in your tests. - -> **Note:** Make sure hardware keyboard is disconnected. Otherwise, Detox may fail when attempting to type text. -> -> To make sure hardware keyboard is disconnected, open the simulator from Xcode and make sure **Hardware** -> **Keyboard** -> **Connect Hardware Keyboard** is deselected (or press ⇧⌘K). - ### `replaceText(text)` -Paste text into a text field. + +Pastes text into a text field. ```js await element(by.id('textField')).replaceText('passcode again'); ``` ### `clearText()` -Clear text from a text field. +Clears text from a text field. ```js await element(by.id('textField')).clearText(); @@ -100,11 +93,11 @@ await element(by.id('textField')).clearText(); ### `scroll(pixels, direction, startPositionX=NaN, startPositionY=NaN)` -Scroll amount of pixels. +Scrolls a given amount of pixels in the provided direction, starting from the provided start positions. pixels - independent device pixels direction - left/right/top/bottom -startPositionX - The X starting scroll position, in percentage; valid input: (0.0, 1.0), `NaN`; default: `NaN`—Choose the best value -startPositionY - The Y starting scroll position, in percentage; valid input: (0.0, 1.0), `NaN`; default: `NaN`—Choose the best value +startPositionX - the X starting scroll position, in percentage; valid input: [0.0, 1.0], `NaN`; default: `NaN`—choose the best value automatically +startPositionY - the Y starting scroll position, in percentage; valid input: [0.0, 1.0], `NaN`; default: `NaN`—choose the best value automatically ```js await element(by.id('scrollView')).scroll(100, 'down', NaN, 0.85); @@ -113,7 +106,7 @@ await element(by.id('scrollView')).scroll(100, 'up'); ### `scrollTo(edge)` -Scroll to edge. +Scrolls to the provided edge. edge - left/right/top/bottom @@ -124,9 +117,11 @@ await element(by.id('scrollView')).scrollTo('top'); ### `swipe(direction, speed, percentage)` +Swipes in the provided direction at the provided speed, started from percentage. + direction - left/right/up/down speed - fast/slow - default is fast -percentage - (optional) screen percentage to swipe as float +percentage - (optional) screen percentage to swipe; valid input: [0.0, 1.0] ```js await element(by.id('scrollView')).swipe('down'); @@ -135,19 +130,35 @@ await element(by.id('scrollView')).swipe('down', 'fast', 0.5); ``` ### `setColumnToValue(column, value)` iOS only +Sets a picker view’s column to the given value. This function supports both date pickers and general picker views. + column - date picker column index value - string value to set in column ```js -await expect(element(by.type('UIPickerView'))).toBeVisible(); -await element(by.type('UIPickerView')).setColumnToValue(1,"6"); -await element(by.type('UIPickerView')).setColumnToValue(2,"34"); +await expect(element(by.id('pickerView'))).toBeVisible(); +await element(by.id('pickerView')).setColumnToValue(1,"6"); +await element(by.id('pickerView')).setColumnToValue(2,"34"); ``` > **Note:** When working with date pickers, you should always set an explicit locale when launching your app in order to prevent flakiness from different date and time styles. See [here](https://github.com/wix/Detox/blob/master/docs/APIRef.DeviceObjectAPI.md#9-launch-with-a-specific-language-ios-only) for more information. +### `setDatePickerDate(dateString, dateFormat)` iOS only + +Sets the date of a date picker to a date generated from the provided string and date format. + +dateString - string representing a date in the supplied dateFormat +dateFormat - format for the dateString supplied + +```js +await expect(element(by.id('datePicker'))).toBeVisible(); +await element(by.id('datePicker')).setDatePickerDate('2019-02-06T05:10:00-08:00', "yyyy-MM-dd'T'HH:mm:ssZZZZZ"); +``` + ### `pinchWithAngle(direction, speed, angle)` iOS only +Pinches in the given direction with speed and angle. + direction - inward/outward speed - slow/fast - default is slow angle - value in radiant - default is 0 @@ -156,13 +167,3 @@ angle - value in radiant - default is 0 await expect(element(by.id('PinchableScrollView'))).toBeVisible(); await element(by.id('PinchableScrollView')).pinchWithAngle('outward', 'slow', 0); ``` - -### `setDatePickerDate(dateString, dateFormat)` iOS only - -dateString - string representing a date in the supplied dateFormat -dateFormat - format for the dateString supplied - -```js -await expect(element(by.type('UIDatePicker'))).toBeVisible(); -await element(by.type('UIDatePicker')).setDatePickerDate('2019-02-06T05:10:00-08:00', "yyyy-MM-dd'T'HH:mm:ssZZZZZ"); -```