Repository: wix/Detox (js)

Issue:
Add toBeFocused expectation

Pull request:
To be focused expectation
## Description

Closes #2766. Added to be focused functionality  

Patch:
diff --git a/detox/ios/Detox/Invocation/Element.swift b/detox/ios/Detox/Invocation/Element.swift
index 3af74c552..097f70e0c 100644
--- a/detox/ios/Detox/Invocation/Element.swift
+++ b/detox/ios/Detox/Invocation/Element.swift
@@ -202,6 +202,10 @@ class Element : NSObject {
 		return rv
 	}
 	
+	func isFocused() -> Bool {
+		return view.dtx_isFocused()
+	}
+	
 	func isHittable() throws -> Bool {
 		var error: NSError? = nil
 		let rv = view.dtx_isHittable(at: view.dtx_accessibilityActivationPointInViewCoordinateSpace, error: &error)
diff --git a/detox/ios/Detox/Invocation/Expectation.swift b/detox/ios/Detox/Invocation/Expectation.swift
index 8c1d7f434..59666285c 100644
--- a/detox/ios/Detox/Invocation/Expectation.swift
+++ b/detox/ios/Detox/Invocation/Expectation.swift
@@ -49,6 +49,7 @@ class Expectation : CustomStringConvertible {
 	
 	struct Kind {
 		static let toBeVisible = "toBeVisible"
+		static let toBeFocused = "toBeFocused"
 		static let toExist = "toExist"
 		static let toHaveText = "toHaveText"
 		static let toHaveLabel = "toHaveLabel"
@@ -72,6 +73,7 @@ class Expectation : CustomStringConvertible {
 	
 	static let mapping : [String: Expectation.Type] = [
 		Kind.toBeVisible: ToBeVisibleExpectation.self,
+		Kind.toBeFocused: ToBeFocusedExpectation.self,
 		Kind.toExist: ToExistExpectation.self,
 		
 		Kind.toHaveText: ValueExpectation.self,
@@ -204,6 +206,12 @@ class ToBeVisibleExpectation : Expectation {
 	}
 }
 
+class ToBeFocusedExpectation : Expectation {
+	override func _evaluate() {
+		dtx_assert(applyModifiers(self.element.isFocused(), modifiers: modifiers), "Failed expectation: \(self.description)", viewDescription: self.element.debugAttributes)
+	}
+}
+
 class ToExistExpectation : Expectation {
 	override func _evaluate() {
 		dtx_assert(applyModifiers(self.element.exists, modifiers: modifiers), "Failed expectation: \(self.description)", viewDescription: self.element.debugAttributes)
diff --git a/detox/ios/Detox/Utilities/NSObject+DetoxUtils.h b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.h
index 681930d43..5f6b1cd32 100644
--- a/detox/ios/Detox/Utilities/NSObject+DetoxUtils.h
+++ b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.h
@@ -38,6 +38,8 @@ static double LNLinearInterpolate(CGFloat from, CGFloat to, CGFloat p)
 - (void)dtx_assertVisible;
 - (void)dtx_assertVisibleVisibleAtRect:(CGRect)rect;
 
+- (BOOL)dtx_isFocused;
+
 @property (nonatomic, readonly) BOOL dtx_isHittable;
 - (BOOL)dtx_isHittableAtPoint:(CGPoint)point;
 - (BOOL)dtx_isHittableAtPoint:(CGPoint)point error:(NSError* __strong * __nullable)error;
diff --git a/detox/ios/Detox/Utilities/NSObject+DetoxUtils.m b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.m
index 97e150f64..5072d1894 100644
--- a/detox/ios/Detox/Utilities/NSObject+DetoxUtils.m
+++ b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.m
@@ -160,6 +160,13 @@ BOOL __DTXPointEqualToPoint(CGPoint a, CGPoint b)
 	[self.dtx_view dtx_assertVisibleVisibleAtRect:self.dtx_bounds];
 }
 
+- (BOOL)dtx_isFocused
+{
+	BOOL isFocused = [self.dtx_view isFocused];
+	BOOL isFirstResponder = [self.dtx_view isFirstResponder];
+	return isFocused || isFirstResponder;
+}
+
 - (BOOL)dtx_isHittable
 {
 	return YES;
diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js
index 300ad0f9b..d2a787113 100644
--- a/detox/src/ios/expectTwo.js
+++ b/detox/src/ios/expectTwo.js
@@ -19,6 +19,14 @@ class Expect {
     return this.not.toBeVisible();
   }
 
+  toBeFocused() {
+    return this.expect('toBeFocused');
+  }
+
+  toBeNotFocused() {
+    return this.not.toBeFocused();
+  }
+
   toExist() {
     return this.expect('toExist');
   }


Test patch:
diff --git a/detox/src/ios/expectTwo.test.js b/detox/src/ios/expectTwo.test.js
index b338b9011..6413ce70f 100644
--- a/detox/src/ios/expectTwo.test.js
+++ b/detox/src/ios/expectTwo.test.js
@@ -272,6 +272,39 @@ describe('expectTwo', () => {
     expect(testCall).deepEquals(jsonOutput);
   });
 
+  it(`expect(element(by.text('Tap Working!!!'))).toBeFocused()`, () => {
+    const testCall = e.expect(e.element(e.by.text('Tap Working!!!'))).toBeFocused();
+    const jsonOutput = {
+      invocation: {
+        type: 'expectation',
+        predicate: {
+          type: 'text',
+          value: 'Tap Working!!!'
+        },
+        expectation: 'toBeFocused'
+      }
+    };
+
+    expect(testCall).deepEquals(jsonOutput);
+  });
+
+  it(`expect(element(by.text('Tap Working!!!'))).toBeNotFocused()`, () => {
+    const testCall = e.expect(e.element(e.by.text('Tap Working!!!'))).toBeNotFocused();
+    const jsonOutput = {
+      invocation: {
+        type: 'expectation',
+        predicate: {
+          type: 'text',
+          value: 'Tap Working!!!'
+        },
+        modifiers: ['not'],
+        expectation: 'toBeFocused'
+      }
+    };
+
+    expect(testCall).deepEquals(jsonOutput);
+  });
+
   it(`expect(element(by.id('UniqueId204'))).toHaveText('I contain some text')`, () => {
     const testCall = e.expect(e.element(e.by.id('UniqueId204'))).toHaveText('I contain some text');
     const jsonOutput = {
diff --git a/detox/test/e2e/31.expectations.test.js b/detox/test/e2e/31.expectations.test.js
new file mode 100644
index 000000000..1f6a977c9
--- /dev/null
+++ b/detox/test/e2e/31.expectations.test.js
@@ -0,0 +1,19 @@
+describe('Expectations', () => {
+  beforeEach(async () => {
+    await device.reloadReactNative();
+    await element(by.text('Expectations')).tap();
+  });
+
+  it(':ios: should expect text fields to be focused after tap but not before', async () => {
+    await element(by.label('Text Fields')).tap();
+    await expect(element(by.id('TextField_Id1'))).toBeVisible();
+    await expect(element(by.id('TextField_Id1'))).toBeNotFocused();
+    await expect(element(by.id('TextField_Id2'))).toBeNotFocused();
+    await element(by.id('TextField_Id1')).tap();
+    await expect(element(by.id('TextField_Id1'))).toBeFocused();
+    await expect(element(by.id('TextField_Id2'))).toBeNotFocused();
+    await element(by.id('TextField_Id2')).tap();
+    await expect(element(by.id('TextField_Id2'))).toBeFocused();
+    await expect(element(by.id('TextField_Id1'))).toBeNotFocused();
+  });
+});
diff --git a/detox/test/src/Screens/ExpectationsScreen.js b/detox/test/src/Screens/ExpectationsScreen.js
new file mode 100644
index 000000000..29019c8f1
--- /dev/null
+++ b/detox/test/src/Screens/ExpectationsScreen.js
@@ -0,0 +1,50 @@
+import React, { Component } from 'react';
+import {
+  Text,
+  View,
+  TouchableOpacity,
+  TextInput
+} from 'react-native';
+
+export default class ExpectationsScreen extends Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      renderTextFields: false
+    };
+  }
+
+  render() {
+    if (this.state.renderTextFields) return this.renderTextFields();
+    return (
+      <View style={{flex: 1, paddingTop: 20, justifyContent: 'center', alignItems: 'center'}}>
+        <TouchableOpacity onPress={this.onTextFieldsPress.bind(this)} accessible={true} accessibilityRole={'button'}>
+          <Text style={{color: 'blue', marginBottom: 20}}>Text Fields</Text>
+        </TouchableOpacity>
+      </View>
+    );
+  }
+
+  renderTextFields() {
+    return (
+      <View style={{flex: 1, paddingTop: 20, justifyContent: 'center', alignItems: 'center'}}>
+        <TextInput style={{ height: 40, borderColor: 'gray', borderWidth: 1, marginBottom: 10, marginHorizontal: 20, padding: 5 }}
+                   value={'First Text Field..'}
+                   testID={'TextField_Id1'}
+        />
+        <TextInput style={{ height: 40, borderColor: 'gray', borderWidth: 1, marginBottom: 10, marginHorizontal: 20, padding: 5 }}
+                   value={'Second Text Field...'}
+                   testID={'TextField_Id2'}
+        />
+      </View>
+    );
+  }
+
+  onTextFieldsPress() {
+    this.setState({
+      renderTextFields: true
+    });
+  }
+
+}
diff --git a/detox/test/src/Screens/index.js b/detox/test/src/Screens/index.js
index 393c2dfba..8870eae0b 100644
--- a/detox/test/src/Screens/index.js
+++ b/detox/test/src/Screens/index.js
@@ -24,6 +24,7 @@ import DeviceScreen from './DeviceScreen';
 import ElementScreenshotScreen from './ElementScreenshotScreen';
 import VirtualizedListStressScreen from './VirtualizedListStressScreen';
 import WebViewScreen from './WebViewScreen';
+import ExpectationsScreen from './ExpectationsScreen';
 
 export {
   SanityScreen,
@@ -51,5 +52,6 @@ export {
   DeviceScreen,
   ElementScreenshotScreen,
   WebViewScreen,
-  VirtualizedListStressScreen,  
+  VirtualizedListStressScreen,
+  ExpectationsScreen
 };
diff --git a/detox/test/src/app.js b/detox/test/src/app.js
index 24a2410b1..971a7466f 100644
--- a/detox/test/src/app.js
+++ b/detox/test/src/app.js
@@ -103,6 +103,7 @@ class example extends Component {
         {this.renderScreenButton('Language', Screens.LanguageScreen)}
         {this.renderScreenButton('Sanity', Screens.SanityScreen)}
         {this.renderScreenButton('Matchers', Screens.MatchersScreen)}
+        {!isAndroid && this.renderScreenButton('Expectations', Screens.ExpectationsScreen)}
         {this.renderScreenButton('Actions', Screens.ActionsScreen)}
         {this.renderScreenButton('Integrative Actions', Screens.IntegActionsScreen)}
         {this.renderScreenButton('FS Scroll Actions', Screens.ScrollActionsScreen)}