Repository: wix/Detox (js)

Issue:
withAncestor modifies original matcher
### What happened?

Calling `withAncestor` (and I suspect, `withDescendant`, and `and`), modifies the original selector it was called upon

### What was the expected behaviour?

I'd expect it to return a new instance of the matcher with the additional filtering applied, without overwriting the original. Overriding the original means that in a test suite, you can't easily share/reuse base matchers.

### Was it tested on latest Detox?

- [X] I have tested this issue on the latest Detox release and it still reproduces.

### Help us reproduce this issue!

Create some simple test with a reusable component, that is then used in two different ancestors, and write a test against that. The below will then show this problem:

```
const childComponent = by.id('child');
const parentComponentA = element(sharedComponent.withAncestor(by.id('parent-a')));

// Expected: MATCHER((id == “child” && ANCESTOR(id == “parent-a”))
// Actual: MATCHER((id == “child” && ANCESTOR(id == “parent-a”)) && ANCESTOR(id == “parent-b”))
const parentComponentB = element(childComponent.withAncestor(by.id('parentB')));
```

### In what environment did this happen?

Detox version: 19.3.0
React Native version: 0.69.6
Node version: 16.14.2
Test-runner (select one): jest-circus

### Detox logs

<details><summary>Detox logs</summary>

```
paste logs here!
```

</details>

### Device logs

<details><summary>Device logs</summary>

```
paste logs here!
```

</details>

### More data, please!

_No response_

Pull request:
fix: immutable matchers
## Description

Resolves #3675

Patch:
diff --git a/detox/src/android/core/NativeMatcher.js b/detox/src/android/core/NativeMatcher.js
index 5c25f4504..9fb524eb5 100644
--- a/detox/src/android/core/NativeMatcher.js
+++ b/detox/src/android/core/NativeMatcher.js
@@ -2,29 +2,33 @@ const invoke = require('../../invoke');
 const DetoxMatcherApi = require('../espressoapi/DetoxMatcher');
 
 class NativeMatcher {
+  constructor(call) {
+    this._call = call || null;
+  }
+
   withAncestor(matcher) {
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherWithAncestor(this, matcher));
-    return this;
+    const call = invoke.callDirectly(DetoxMatcherApi.matcherWithAncestor(this, matcher));
+    return new NativeMatcher(call);
   }
 
   withDescendant(matcher) {
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherWithDescendant(this, matcher));
-    return this;
+    const call = invoke.callDirectly(DetoxMatcherApi.matcherWithDescendant(this, matcher));
+    return new NativeMatcher(call);
   }
 
   and(matcher) {
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForAnd(this, matcher));
-    return this;
+    const call = invoke.callDirectly(DetoxMatcherApi.matcherForAnd(this, matcher));
+    return new NativeMatcher(call);
   }
 
   or(matcher) {
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForOr(this, matcher));
-    return this;
+    const call = invoke.callDirectly(DetoxMatcherApi.matcherForOr(this, matcher));
+    return new NativeMatcher(call);
   }
 
   get not() {
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForNot(this));
-    return this;
+    const call = invoke.callDirectly(DetoxMatcherApi.matcherForNot(this));
+    return new NativeMatcher(call);
   }
 
   _avoidProblematicReactNativeElements() {
diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js
index 54a4a59d6..c5467f3da 100644
--- a/detox/src/ios/expectTwo.js
+++ b/detox/src/ios/expectTwo.js
@@ -438,8 +438,7 @@ class Matcher {
       throwMatcherError(matcher);
     }
 
-    this.and({ predicate: { type: 'ancestor', predicate: matcher.predicate } });
-    return this;
+    return this.and({ predicate: { type: 'ancestor', predicate: matcher.predicate } });
   }
 
   withDescendant(matcher) {
@@ -447,21 +446,30 @@ class Matcher {
       throwMatcherError(matcher);
     }
 
-    this.and({ predicate: { type: 'descendant', predicate: matcher.predicate } });
-    return this;
+    return this.and({ predicate: { type: 'descendant', predicate: matcher.predicate } });
   }
 
   and(matcher) {
-    const tempPredicate = this.predicate;
-    delete this.predicate;
-    this.predicate = { type: 'and', predicates: [] };
-    this.predicate.predicates.push(tempPredicate);
+    const result = new Matcher();
+
+    result.predicate = {
+      type: 'and',
+      predicates: [
+        ...Matcher.predicates(this),
+        ...Matcher.predicates(matcher),
+      ].map(x => _.cloneDeep(x))
+    };
+
+    return result;
+  }
+
+  /** @private */
+  static *predicates(matcher) {
     if (matcher.predicate.type === 'and') {
-      this.predicate.predicates = this.predicate.predicates.concat(matcher.predicate.predicates);
+      yield* matcher.predicate.predicates;
     } else {
-      this.predicate.predicates.push(matcher.predicate);
+      yield matcher.predicate;
     }
-    return this;
   }
 }
 


Test patch:
diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js
index dda0b69a4..82c32591e 100644
--- a/detox/src/android/AndroidExpect.test.js
+++ b/detox/src/android/AndroidExpect.test.js
@@ -93,6 +93,19 @@ describe('AndroidExpect', () => {
       await e.expect(e.element(e.by.id('test').or(e.by.id('test2')))).toBeVisible();
     });
 
+    test.each([
+      ['withAncestor'],
+      ['withDescendant'],
+      ['and'],
+    ])(`matcher immutability: %s`, async (combineMethodName) => {
+      const base = e.by.id('test');
+      const modifier = e.by.id('ancestor');
+
+      expect(base[combineMethodName](modifier)).not.toBe(base);
+      expect(base).toEqual(e.by.id('test'));
+      expect(modifier).toEqual(e.by.id('ancestor'));
+    });
+
     it(`should throw for invalid toBeVisible parameters`, async () => {
       await expectToThrow(() =>e.expect(e.element(e.by.label('test'))).toBeVisible(0));
       await expectToThrow(() =>e.expect(e.element(e.by.label('test'))).toBeVisible(120));
diff --git a/detox/src/ios/expectTwo.test.js b/detox/src/ios/expectTwo.test.js
index 19c09839a..4553a00d5 100644
--- a/detox/src/ios/expectTwo.test.js
+++ b/detox/src/ios/expectTwo.test.js
@@ -115,6 +115,19 @@ describe('expectTwo', () => {
     expect(testCall).toDeepEqual(jsonOutput);
   });
 
+  it.each([
+    ['withAncestor'],
+    ['withDescendant'],
+    ['and'],
+  ])(`should produce immutable objects when combining matchers: %s`, async (combineMethodName) => {
+    const base = e.by.id('abc');
+    const modifier = e.by.id('def');
+
+    expect(base[combineMethodName](modifier)).not.toBe(base);
+    expect(base).toEqual(e.by.id('abc'));
+    expect(modifier).toEqual(e.by.id('def'));
+  });
+
   it(`should produce correct JSON for element with ancestor and index matchers`, async () => {
     const testCall = await e.element(e.by.id('child').withAncestor(e.by.id('parent'))).atIndex(0).tap();
     const jsonOutput = {
@@ -577,7 +590,7 @@ expect.extend({
       pass,
       actual: a,
       expected: b,
-      message: () => `${JSON.stringify(a)} does not match 
+      message: () => `${JSON.stringify(a)} does not match
        ${JSON.stringify(b)}`
     };
   }