Skip to content

Commit dcaad95

Browse files
committed
fix(ios): treat a bullet-only field value as unreadable during fill
A React Native secureTextEntry input reaches XCTest as a plain textField whose value is the masked rendering, so the runner compared the typed text against bullets and every password fill failed with TEXT_ENTRY_MISMATCH. The failed verification then ran the repair, whose clear taps the field's right edge and flipped the show/hide toggle that overlays it. editableTextValue now returns nil for a value made only of password bullets, the same unverifiable result a .secureTextField already gets: the fill reports typed and unverified, and the repair never runs. Host app gains --agent-device-text-entry-masked-value, a textField whose accessibility value is bullets with a toggle over its right edge, so the simulator test reproduces the report: without the guard it fails with the same message and presses the toggle once. Fixes e2e-inc/e2e#872
1 parent c13c1bd commit dcaad95

4 files changed

Lines changed: 143 additions & 0 deletions

File tree

‎apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,31 @@ int main(int argc, const char *argv[]) {
6060
#import <UserNotifications/UserNotifications.h>
6161
#endif
6262

63+
#if TARGET_OS_IOS
64+
// A plain text field whose accessibility value is the masked rendering of its text. This is how
65+
// a React Native `secureTextEntry` input reaches XCTest: `.textField`, not `.secureTextField`,
66+
// with a value of bullets (tester-army/e2e#872).
67+
@interface AgentDeviceMaskedTextField : UITextField
68+
@end
69+
70+
@implementation AgentDeviceMaskedTextField
71+
72+
- (NSString *)accessibilityValue {
73+
NSUInteger length = self.text.length;
74+
if (length == 0) {
75+
return [super accessibilityValue];
76+
}
77+
// Half the typed length, as observed in the report: the mask hides the count as well.
78+
NSUInteger bullets = MAX((NSUInteger)1, length / 2);
79+
return [@"" stringByPaddingToLength:bullets withString:@"\u2022" startingAtIndex:0];
80+
}
81+
82+
@end
83+
#endif
84+
6385
@interface AgentDeviceRunnerViewController : UIViewController
86+
@property(nonatomic, strong) UILabel *maskedToggleStatus;
87+
@property(nonatomic, assign) NSUInteger maskedTogglePresses;
6488
@property(nonatomic, strong) UILabel *alertActionStatus;
6589
@property(nonatomic, strong) UILabel *alertActivationBusyAnswer;
6690
@property(nonatomic, assign) NSUInteger firstAlertActions;
@@ -486,6 +510,19 @@ - (void)agentDeviceTextEntryDidBeginEditing:(UITextField *)textField {
486510
}
487511
#endif
488512

513+
#if TARGET_OS_IOS
514+
- (void)agentDeviceMaskedToggleDidPress:(UIButton *)sender {
515+
(void)sender;
516+
self.maskedTogglePresses += 1;
517+
[self updateMaskedToggleStatus];
518+
}
519+
520+
- (void)updateMaskedToggleStatus {
521+
self.maskedToggleStatus.text =
522+
[NSString stringWithFormat:@"presses=%lu", (unsigned long)self.maskedTogglePresses];
523+
}
524+
#endif
525+
489526
- (void)viewDidLoad {
490527
[super viewDidLoad];
491528

@@ -533,6 +570,8 @@ - (void)viewDidLoad {
533570
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-currency-value"];
534571
BOOL submitClears =
535572
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-submit-clears"];
573+
BOOL maskedValue =
574+
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-masked-value"];
536575
UITextField *textField;
537576
if (digitCountValue) {
538577
textField = [[AgentDeviceDigitCountTextField alloc] init];
@@ -543,6 +582,8 @@ - (void)viewDidLoad {
543582
textField.text = @"$0.00";
544583
} else if (submitClears) {
545584
textField = [[AgentDeviceSubmitClearingTextField alloc] init];
585+
} else if (maskedValue) {
586+
textField = [[AgentDeviceMaskedTextField alloc] init];
546587
} else {
547588
textField = [[UITextField alloc] init];
548589
}
@@ -621,6 +662,30 @@ - (void)viewDidLoad {
621662
[neighbour.heightAnchor constraintEqualToConstant:AgentDeviceTextEntryFieldHeight],
622663
]];
623664
}
665+
if (maskedValue) {
666+
// The show/hide toggle sits over the field's right edge, where the runner's caret edge-tap
667+
// lands. Counting its presses tells the test whether a repair reached for the field again.
668+
UIButton *toggle = [UIButton buttonWithType:UIButtonTypeSystem];
669+
toggle.accessibilityLabel = @"Show password";
670+
toggle.translatesAutoresizingMaskIntoConstraints = NO;
671+
[toggle addTarget:self
672+
action:@selector(agentDeviceMaskedToggleDidPress:)
673+
forControlEvents:UIControlEventTouchUpInside];
674+
[self.view addSubview:toggle];
675+
self.maskedToggleStatus = [[UILabel alloc] init];
676+
self.maskedToggleStatus.accessibilityIdentifier = @"agent-device-masked-toggle-presses";
677+
self.maskedToggleStatus.translatesAutoresizingMaskIntoConstraints = NO;
678+
[self.view addSubview:self.maskedToggleStatus];
679+
[NSLayoutConstraint activateConstraints:@[
680+
[toggle.trailingAnchor constraintEqualToAnchor:textField.trailingAnchor],
681+
[toggle.topAnchor constraintEqualToAnchor:textField.topAnchor],
682+
[toggle.bottomAnchor constraintEqualToAnchor:textField.bottomAnchor],
683+
[toggle.widthAnchor constraintEqualToConstant:48],
684+
[self.maskedToggleStatus.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
685+
[self.maskedToggleStatus.topAnchor constraintEqualToAnchor:textField.bottomAnchor constant:12],
686+
]];
687+
[self updateMaskedToggleStatus];
688+
}
624689
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-app-owned-value"]) {
625690
self.textEntryAcknowledgeWindowSeconds = AgentDeviceTextEntryAcknowledgeWindow();
626691
// Reports how many edits this app rendered and how many writes it had to make because a

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,12 @@ extension RunnerTests {
458458
if treatingPlaceholderAsEmpty && isPlaceholderValue(value, for: element) {
459459
return ""
460460
}
461+
// A React Native `secureTextEntry` field reports as a plain text field whose value is the
462+
// masked rendering, so it is as unreadable as a `.secureTextField` and must not be compared
463+
// against the typed text (tester-army/e2e#872).
464+
if Self.isMaskedTextValue(value) {
465+
return nil
466+
}
461467
return value
462468
case .secureTextField:
463469
return nil
@@ -466,6 +472,21 @@ extension RunnerTests {
466472
}
467473
}
468474

475+
/// Whether an accessibility value is a masked rendering of a secure field: one or more
476+
/// password bullets and nothing else. The bullet count need not match the typed length, so
477+
/// such a value carries no information about what the field holds.
478+
static func isMaskedTextValue(_ value: String) -> Bool {
479+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
480+
guard !trimmed.isEmpty else {
481+
return false
482+
}
483+
return trimmed.allSatisfy { Self.passwordMaskCharacters.contains($0) }
484+
}
485+
486+
/// U+2022 is what UIKit renders for a secure field; U+25CF is the other bullet some custom
487+
/// inputs substitute for it.
488+
private static let passwordMaskCharacters: Set<Character> = ["\u{2022}", "\u{25CF}"]
489+
469490
private func isPlaceholderValue(_ value: String, for element: any XCUIElementAttributes) -> Bool {
470491
if Self.textMatchesPlaceholder(value, placeholder: element.placeholderValue) {
471492
return true

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ extension RunnerTests {
9898
}
9999
}
100100

101+
// A masked value is bullets and nothing else. Anything with a real character in it is a value
102+
// the runner can verify, and an empty string is "empty", not "masked", so a replacement into
103+
// an empty field still skips the clear.
104+
func testMaskedTextValueIsBulletsOnly() {
105+
let masked = [
106+
"\u{2022}",
107+
String(repeating: "\u{2022}", count: 8),
108+
String(repeating: "\u{25CF}", count: 3),
109+
" \u{2022}\u{2022} ",
110+
]
111+
for value in masked {
112+
XCTAssertTrue(Self.isMaskedTextValue(value), "value: \(value)")
113+
}
114+
let readable = ["", " ", "not-a-secret-123", "\u{2022} bullet point", "*****", "Password"]
115+
for value in readable {
116+
XCTAssertFalse(Self.isMaskedTextValue(value), "value: \(value)")
117+
}
118+
}
119+
101120
// A value with a hole in the middle is neither a matching prefix nor an exact match, and must
102121
// never settle. These are the two corruption strings actually observed in CI on `fill`
103122
// (id="field-name" "Ada Lovelace" -> "Avelace", id="field-email" "ada@example" -> "aexample";

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,44 @@ extension RunnerTests {
383383
XCTAssertEqual(observed, text)
384384
}
385385

386+
// A React Native `secureTextEntry` field is a `.textField` whose value reads back as bullets, so
387+
// the typed text can never match it. The fill must report typed and unverified instead of a
388+
// mismatch, and must not run the repair, whose clear taps the field's right edge where a
389+
// show/hide toggle commonly sits (tester-army/e2e#872).
390+
@MainActor
391+
func testFillIntoMaskedTextFieldIsUnverifiedRatherThanAMismatch() throws {
392+
app.launchArguments = [
393+
"--agent-device-text-entry-regression",
394+
"--agent-device-text-entry-masked-value",
395+
]
396+
app.launch()
397+
addTeardownBlock { [self] in
398+
invalidateCachedTarget(reason: "unit_test_cleanup")
399+
app.terminate()
400+
}
401+
XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout))
402+
let textField = app.textFields["agent-device-hardware-keyboard-input"]
403+
XCTAssertTrue(textField.waitForExistence(timeout: appExistenceTimeout))
404+
let togglePresses = app.staticTexts["agent-device-masked-toggle-presses"]
405+
XCTAssertTrue(togglePresses.waitForExistence(timeout: appExistenceTimeout))
406+
XCTAssertEqual(togglePresses.label, "presses=0")
407+
try tapHardwareKeyboardInput(commandId: "tap-masked-input")
408+
409+
let failureCountBefore = currentXCTestFailureCount()
410+
let fillResponse = executeTypeCommand(
411+
activeApp: app,
412+
command: try runnerCommandFixture(
413+
#"{"command":"type","commandId":"fill-masked-input","text":"not-a-secret-123","textEntryMode":"replace"}"#
414+
)
415+
)
416+
417+
XCTAssertTrue(fillResponse.ok, String(describing: fillResponse.error))
418+
XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore))
419+
XCTAssertEqual(fillResponse.data?.message, "typed")
420+
XCTAssertEqual(textField.value as? String, String(repeating: "\u{2022}", count: 8))
421+
XCTAssertEqual(togglePresses.label, "presses=0")
422+
}
423+
386424
private struct UnavailableTextEntrySynthesizer: TextEntrySynthesizing {
387425
func enterText(
388426
app _: XCUIApplication,

0 commit comments

Comments
 (0)