Repository: wix/Detox
Pull request #3936 (2023-02-15 13:57:58)

fix: missing method waitFor(...).toBeFocused()
## Description

I've run into this in my tests:
```
    TypeError: (0 , _detox.waitFor)(...).toBeFocused is not a function

       6 |     await waitFor(element(by.id('email-input')))
    >  7 |       .toBeFocused()
         |        ^
       8 |       .withTimeout(5000);
```
TypeScript suggested it was possible, but the methods were missing

asafkorem: @janlat Thanks for the contribution! Will take a look later today. 🙏🏼 

Patch:
diff --git a/detox/src/android/core/NativeWaitFor.js b/detox/src/android/core/NativeWaitFor.js
index 7e4c7bcfa..6928e9b4d 100644
--- a/detox/src/android/core/NativeWaitFor.js
+++ b/detox/src/android/core/NativeWaitFor.js
@@ -65,6 +65,14 @@ class NativeWaitForElement extends NativeWaitFor {
   toNotHaveValue(value) {
     return this.not.toHaveValue(value);
   }
+
+  toBeFocused() {
+    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.FocusMatcher().not : new matchers.FocusMatcher());
+  }
+
+  toBeNotFocused() {
+    return this.not.toBeFocused();
+  }
 }
 
 module.exports = {
diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js
index 8d4c8428e..3415b420b 100644
--- a/detox/src/ios/expectTwo.js
+++ b/detox/src/ios/expectTwo.js
@@ -545,6 +545,16 @@ class WaitFor {
     return this;
   }
 
+  toBeFocused() {
+    this.expectation = this.expectation.toBeFocused();
+    return this;
+  }
+
+  toBeNotFocused() {
+    this.expectation = this.expectation.toBeNotFocused();
+    return this;
+  }
+
   get not() {
     this.expectation.not;
     return this;


Test patch:
diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js
index 6252c4684..8b26a716c 100644
--- a/detox/src/android/AndroidExpect.test.js
+++ b/detox/src/android/AndroidExpect.test.js
@@ -165,6 +165,8 @@ describe('AndroidExpect', () => {
       await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).scroll(50, 'down');
       await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).scroll(50);
 
+      await e.waitFor(e.element(e.by.id('id'))).toBeFocused().withTimeout(0);
+      await e.waitFor(e.element(e.by.id('id'))).toBeNotFocused().withTimeout(0);
     });
 
     it(`waitFor (element) with wrong parameters should throw`, async () => {
diff --git a/detox/src/ios/expectTwoApiCoverage.test.js b/detox/src/ios/expectTwoApiCoverage.test.js
index 4284d56da..f70e6a568 100644
--- a/detox/src/ios/expectTwoApiCoverage.test.js
+++ b/detox/src/ios/expectTwoApiCoverage.test.js
@@ -223,6 +223,8 @@ describe('expectTwo API Coverage', () => {
       await e.waitFor(e.element(e.by.id('id'))).toNotHaveLabel('value');
       await e.waitFor(e.element(e.by.id('id'))).toHaveValue('value');
       await e.waitFor(e.element(e.by.id('id'))).toNotHaveValue('value');
+      await e.waitFor(e.element(e.by.id('id'))).toBeFocused().withTimeout(0);
+      await e.waitFor(e.element(e.by.id('id'))).toBeNotFocused().withTimeout(0);
     });
 
     it(`waitFor (element) with wrong parameters should throw`, async () => {
diff --git a/detox/test/e2e/05.waitfor.test.js b/detox/test/e2e/05.waitfor.test.js
index 4aa095bd6..6efd4234a 100644
--- a/detox/test/e2e/05.waitfor.test.js
+++ b/detox/test/e2e/05.waitfor.test.js
@@ -1,49 +1,75 @@
 const custom = require('./utils/custom-it');
 const {expectToThrow} = require('./utils/custom-expects');
 
+const expectToFinishBeforeTimeout = async (block, timeout) => {
+  const startTime = new Date().getTime();
+  await block();
+  const endTime = new Date().getTime();
+
+  const expiredAfter = endTime - startTime;
+  if (expiredAfter > timeout) {
+    throw new Error(`Action not expired even after a timeout, took ${expiredAfter}ms`);
+  }
+}
+
 describe('WaitFor', () => {
+  const goButton = element(by.id('goButton'));
+  const timeout = 5000;
+
   beforeEach(async() => {
     await device.reloadReactNative();
     await element(by.text('WaitFor')).tap();
   });
 
-  it('should wait until an element is created and exists in layout', async () => {
-    await expect(element(by.id('createdAndVisibleText'))).not.toExist();
-    await element(by.id('GoButton')).tap();
-    await waitFor(element(by.id('createdAndVisibleText'))).toExist().withTimeout(20000);
+  it('should wait until an element is exists / removed in layout', async () => {
+    let testElement = element(by.id('changeExistenceByToggle'));
+    await expect(testElement).not.toExist();
+
+    await goButton.tap();
+
+    await expectToFinishBeforeTimeout(async () => {
+      await waitFor(testElement).toExist().withTimeout(timeout);
+    }, timeout);
+
+    await goButton.tap();
+
+    await expectToFinishBeforeTimeout(async () => {
+      await waitFor(testElement).not.toExist().withTimeout(timeout);
+    }, timeout);
   });
 
-  it('should wait until an element is removed', async () => {
-    const timeout = 20000;
+  it('should wait until an element is focused / unfocused', async () => {
+    let testElement = element(by.id('changeFocusByToggle'));
+    await expect(testElement).not.toBeFocused();
 
-    await expect(element(by.id('deletedFromHierarchyText'))).toBeVisible();
-    await element(by.id('GoButton')).tap();
+    await goButton.tap();
 
-    const startTime = new Date().getTime();
-    await waitFor(element(by.id('deletedFromHierarchyText'))).not.toBeVisible().withTimeout(timeout);
-    const endTime = new Date().getTime();
+    await expectToFinishBeforeTimeout(async () => {
+      await waitFor(testElement).toBeFocused().withTimeout(timeout);
+    }, timeout);
 
-    if (endTime - startTime > timeout) {
-      throw new Error(`Action not expired even after a timeout`);
-    }
+    await goButton.tap();
 
-    await expect(element(by.id('deletedFromHierarchyText'))).not.toBeVisible();
+    await expectToFinishBeforeTimeout(async () => {
+      await waitFor(testElement).not.toBeFocused().withTimeout(timeout);
+    }, timeout);
   });
 
   custom.it.withFailureIf.android('should find element by scrolling until it is visible', async () => {
     await expect(element(by.text('Text5'))).not.toBeVisible();
-    await element(by.id('GoButton')).tap();
+
+    await goButton.tap();
     await waitFor(element(by.text('Text5'))).toBeVisible().whileElement(by.id('ScrollView')).scroll(50, 'down');
     await expect(element(by.text('Text5'))).toBeVisible();
   });
 
   it('should fail test after waiting for element to exist but it doesn\'t at the end', async () => {
     await expect(element(by.id('neverAppearingText'))).not.toExist();
-    await expectToThrow(() => waitFor(element(by.id('neverAppearingText'))).toExist().withTimeout(1000));
+    await expectToThrow(() => waitFor(element(by.id('neverAppearingText'))).toExist().withTimeout(500));
   });
 
   it('should abort scrolling if element was not found', async () => {
-    await element(by.id('GoButton')).tap();
+    await goButton.tap();
     await expectToThrow(() => waitFor(element(by.text('Text1000'))).toBeVisible().whileElement(by.id('ScrollView')).scroll(50, 'down'));
     await expect(element(by.text('Text1000'))).not.toBeVisible();
   });
diff --git a/detox/test/src/Screens/WaitForScreen.js b/detox/test/src/Screens/WaitForScreen.js
index 62b961343..b87dad809 100644
--- a/detox/test/src/Screens/WaitForScreen.js
+++ b/detox/test/src/Screens/WaitForScreen.js
@@ -4,19 +4,32 @@ import {
   View,
   ScrollView,
   Animated,
-  TouchableOpacity
+  TouchableOpacity,
+  TextInput
 } from 'react-native';
 
 export default class WaitForScreen extends Component {
-
   constructor(props) {
     super(props);
+    this.textInputRef = React.createRef();
     this.state = {
-      clicked: false,
+      toggle: false,
       becomeVisibleLeft: new Animated.Value(-500)
     };
   }
 
+  componentDidUpdate(prevProps, prevState, _) {
+    if (prevState.toggle === this.state.toggle) {
+      return;
+    }
+
+    if (this.state.toggle) {
+      this.textInputRef.current.focus();
+    } else {
+      this.textInputRef.current.blur();
+    }
+  }
+
   render() {
     return (
       <View style={{flex: 1, paddingTop: 40, justifyContent: 'flex-start'}}>
@@ -34,16 +47,21 @@ export default class WaitForScreen extends Component {
           </ScrollView>
         </View>
 
-        {this.state.clicked ? false :
-         <Text testID='deletedFromHierarchyText' style={{marginBottom: 20, textAlign: 'center', color: 'red'}}>I am being removed 2 sec after click</Text>
+        {!this.state.toggle ? false :
+          <Text testID='changeExistenceByToggle' style={{marginBottom: 20, textAlign: 'center', color: 'red'}}>
+            I am being exist / removed 2 sec after click
+          </Text>
         }
 
-        {!this.state.clicked ? false :
-         <Text testID='createdAndVisibleText' style={{marginBottom: 20, textAlign: 'center'}}>I am being created 2 sec after click</Text>
-        }
+        <TextInput
+          testID='changeFocusByToggle'
+          style={{marginBottom: 20}}
+          value='I am focused / unfocused 2 sec after click'
+          ref={this.textInputRef}
+        />
 
         <TouchableOpacity onPress={this.onGoButtonPress.bind(this)}>
-          <Text testID='GoButton' style={{color: 'blue', textAlign: 'center'}}>Go</Text>
+          <Text testID='goButton' style={{color: 'blue', textAlign: 'center'}}>Go</Text>
         </TouchableOpacity>
 
       </View>
@@ -52,8 +70,10 @@ export default class WaitForScreen extends Component {
 
   onGoButtonPress() {
     setTimeout(() => {
+      let previousState = this.state.toggle;
+
       this.setState({
-        clicked: true
+        toggle: !previousState
       });
     }, 2000);
   }