Skip to content

Commit 48ba4b5

Browse files
noomorphclaude
andcommitted
feat(ios): implement regex support for toHaveText
Wire up the existing matchesJSRegex utility (already used by ValuePredicate for by.text/by.id/by.label) to the ValueExpectation path so that toHaveText(/regex/) works on iOS, matching Android behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4b84d9c commit 48ba4b5

6 files changed

Lines changed: 55 additions & 21 deletions

File tree

detox/ios/Detox/Invocation/Expectation.swift

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ class Expectation : CustomStringConvertible {
114114
} else if expectationClass == SliderPositionExpectation.self {
115115
return SliderPositionExpectation(kind: kind, modifiers: modifiers, element: element, timeout: timeout, value: params!.first! as! Double, tolerance: params!.count > 1 ? (params![1] as! Double) : nil)
116116
} else if expectationClass == ValueExpectation.self {
117-
return ValueExpectation(kind: kind, modifiers: modifiers, element: element, timeout: timeout, key: keyMapping[kind]!, value: params!.first!)
117+
let rawParams = dictionaryRepresentation[Keys.params] as? [Any]
118+
let isRegex = rawParams != nil && rawParams!.count > 1 ? (rawParams![1] as? Bool ?? false) : false
119+
return ValueExpectation(kind: kind, modifiers: modifiers, element: element, timeout: timeout, key: keyMapping[kind]!, value: params!.first!, isRegex: isRegex)
118120
} else if expectationClass == ToBeVisibleExpectation.self {
119121
let percentDouble = params != nil && params!.count > 0 ? params!.first as? Double : nil
120122
let percent = percentDouble != nil ? UInt(exactly: percentDouble!) : nil
@@ -250,25 +252,32 @@ class ToExistExpectation : Expectation {
250252
class ValueExpectation : Expectation {
251253
let key : String
252254
let value : CustomStringConvertible
253-
254-
required init(kind: String, modifiers: Set<String>, element: Element, timeout: TimeInterval, key: String, value: CustomStringConvertible) {
255+
let isRegex : Bool
256+
257+
required init(kind: String, modifiers: Set<String>, element: Element, timeout: TimeInterval, key: String, value: CustomStringConvertible, isRegex: Bool = false) {
255258
self.key = key
256259
self.value = value
257-
260+
self.isRegex = isRegex
261+
258262
super.init(kind: kind, modifiers: modifiers, element: element, timeout: timeout)
259263
}
260-
264+
261265
required init(kind: String, modifiers: Set<String>, element: Element, timeout: TimeInterval) {
262266
fatalError("Call the other initializer")
263267
}
264-
268+
265269
override func evaluate(with element: Element) -> Bool {
270+
if isRegex {
271+
guard let elementValue = NSExpression(forKeyPath: key).expressionValue(with: element, context: nil) as? String,
272+
let regexString = value as? String else { return false }
273+
return elementValue.matchesJSRegex(to: regexString)
274+
}
266275
return NSComparisonPredicate(leftExpression: NSExpression(forKeyPath: key), rightExpression: NSExpression(forConstantValue: value), modifier: .direct, type: .equalTo, options: []).evaluate(with: element)
267276
}
268-
277+
269278
override var additionalDescription: String {
270279
get {
271-
return "(\(key) == “\(value)”)"
280+
return isRegex ? "(\(key) matches \(value))" : "(\(key) == “\(value)”)"
272281
}
273282
}
274283
}

detox/ios/Detox/Utilities/String+matchesJSRegex.swift

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,12 @@ extension String {
1919
let pattern = pattern(from: jsRegex, flagsChars: flagsChars)
2020
let options = regexOptions(from: flagsChars)
2121

22-
let regex = try! NSRegularExpression(pattern: pattern, options: options)
23-
let searchRange = NSRange(location: 0, length: self.utf16.count)
24-
let match = regex.firstMatch(
25-
in: self,
26-
options: [],
27-
range: searchRange
28-
)
29-
30-
guard let match = match else {
22+
guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
3123
return false
3224
}
33-
34-
return searchRange == match.range
25+
let searchRange = NSRange(location: 0, length: self.utf16.count)
26+
let match = regex.firstMatch(in: self, options: [], range: searchRange)
27+
return match.map { $0.range == searchRange } ?? false
3528
}
3629

3730
private func flagsChars(from jsRegex: String) -> [Character] {

detox/src/ios/expectTwo.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ class Expect {
7878
}
7979

8080
toHaveText(text) {
81+
const isRegex = isRegExp(text);
8182
const traceDescription = expectDescription.toHaveText(text);
82-
return this.expect('toHaveText', traceDescription, text);
83+
return this.expect('toHaveText', traceDescription, isRegex ? text.toString() : text, isRegex || undefined);
8384
}
8485

8586
toNotHaveText(text) {

detox/src/ios/expectTwo.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,24 @@ describe('expectTwo', () => {
483483
expect(testCall).toDeepEqual(jsonOutput);
484484
});
485485

486+
it(`should parse correct JSON for toHaveText expectation with RegExp`, async () => {
487+
const testCall = await e.expect(e.element(e.by.id('UniqueId204'))).toHaveText(/I contain .* text/i);
488+
const jsonOutput = {
489+
invocation: {
490+
type: 'expectation',
491+
predicate: {
492+
type: 'id',
493+
value: 'UniqueId204',
494+
isRegex: false,
495+
},
496+
expectation: 'toHaveText',
497+
params: ['/I contain .* text/i', true]
498+
}
499+
};
500+
501+
expect(testCall).toDeepEqual(jsonOutput);
502+
});
503+
486504
it(`should parse correct JSON for toHaveId expectation`, async () => {
487505
const testCall = await e.expect(e.element(e.by.text('Product')).atIndex(2)).toHaveId('ProductId002');
488506
const jsonOutput = {

detox/test/e2e/04.assertions.test.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ describe('Assertions', () => {
3737
await expect(driver.textElement).toHaveText('I contain some text');
3838
});
3939

40+
it('should assert an element has text matching a regex', async () => {
41+
await expect(driver.textElement).toHaveText(/I contain .* text/);
42+
});
43+
44+
it('should assert an element does not have text matching a regex', async () => {
45+
await expect(driver.textElement).not.toHaveText(/I contain .* banana/);
46+
});
47+
4048
it('should assert an element has (accessibility) label', async () => {
4149
await expect(driver.textElement).toHaveLabel('I contain some text');
4250
});

docs/api/expect.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,15 @@ await expect(element(by.id('emailInput'))).toBeFocused();
5050

5151
### `toHaveText(text)`
5252

53-
Expects the element to have the specified text.
53+
Expects the element to have the specified text. Accepts a string for exact matching or a regular expression for pattern matching (see [regex flags](matchers.md#regex-matching)).
54+
55+
:::note
56+
When using a regular expression, it must match the **entire** text of the element on both iOS and Android — partial matches are not supported. Use `.*` to match surrounding content, e.g. `/.*Hello.*/` to check that the text contains "Hello".
57+
:::
5458

5559
```js
5660
await expect(element(by.id('mainTitle'))).toHaveText('Welcome back!');
61+
await expect(element(by.id('dynamicTitle'))).toHaveText(/^Welcome back.*/i);
5762
```
5863

5964
### `toHaveLabel(label)`

0 commit comments

Comments
 (0)