Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#import "AgentDeviceRunnerApp.h"
#import <UIKit/UIKit.h>
#import <UserNotifications/UserNotifications.h>
#import "UnitTests/AgentDeviceTextEntryFields.h"

// Mirrors the App Tracking Transparency prompt's real button order: the affirmative button is
// last (#3627). The answer label records which button the app actually took, so a test asserts
Expand All @@ -35,26 +36,6 @@
return alert;
}

// A plain text field whose accessibility value is the masked rendering of its text. This is how
// a React Native `secureTextEntry` input reaches XCTest: `.textField`, not `.secureTextField`,
// with a value of bullets (tester-army/e2e#872).
@interface AgentDeviceMaskedTextField : UITextField
@end

@implementation AgentDeviceMaskedTextField

- (NSString *)accessibilityValue {
NSUInteger length = self.text.length;
if (length == 0) {
return [super accessibilityValue];
}
// Half the typed length, as observed in the report: the mask hides the count as well.
NSUInteger bullets = MAX((NSUInteger)1, length / 2);
return [@"" stringByPaddingToLength:bullets withString:@"\u2022" startingAtIndex:0];
}

@end

@interface AgentDeviceRunnerFixtureViewController : AgentDeviceRunnerViewController
@end

Expand Down Expand Up @@ -105,39 +86,6 @@ - (NSString *)accessibilityValue {
}
@end

// XCTest reads every element's accessibility value each time it snapshots the app, which it does
// for every element query and every attribute read from a live element. Counting this field's
// value reads therefore counts the accessibility queries a command makes. The value is masked, so a
// fill's read-back ends at its first read instead of polling through a stability window whose
// poll count depends on how fast the host answers.
static NSUInteger AgentDeviceTextEntryValueReads = 0;

@interface AgentDeviceValueReadCountingTextField : AgentDeviceMaskedTextField
@end

@implementation AgentDeviceValueReadCountingTextField
- (NSString *)accessibilityValue {
AgentDeviceTextEntryValueReads += 1;
return [super accessibilityValue];
}
@end

// Reports the read count as its accessibility label and the counted field's text as its value, so
// reading either changes no view state and the masked text stays checkable.
@interface AgentDeviceValueReadCountLabel : UILabel
@property(nonatomic, weak) UITextField *countedField;
@end

@implementation AgentDeviceValueReadCountLabel
- (NSString *)accessibilityLabel {
return [NSString stringWithFormat:@"%lu", (unsigned long)AgentDeviceTextEntryValueReads];
}

- (NSString *)accessibilityValue {
return self.countedField.text;
}
@end

// A field that reformats its own text on every edit into `## ### ## ##` digit groups, the way a
// digit-grouping formatter in a Flutter or React Native input does. The accessibility value is
// what the user sees — the formatted string — so it never equals the digits a `fill` sent (#2634).
Expand Down Expand Up @@ -712,6 +660,8 @@ - (void)viewDidLoad {
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-masked-value"];
BOOL countsValueReads =
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-counts-value-reads"];
BOOL witnessesReplacement =
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-witness"];
UITextField *textField;
if (digitCountValue) {
textField = [[AgentDeviceDigitCountTextField alloc] init];
Expand All @@ -724,6 +674,8 @@ - (void)viewDidLoad {
textField = [[AgentDeviceSubmitClearingTextField alloc] init];
} else if (maskedValue) {
textField = [[AgentDeviceMaskedTextField alloc] init];
} else if (witnessesReplacement) {
textField = [[AgentDeviceTextEntryWitnessField alloc] init];
} else if (countsValueReads) {
textField = [[AgentDeviceValueReadCountingTextField alloc] init];
} else {
Expand Down Expand Up @@ -761,7 +713,7 @@ - (void)viewDidLoad {
[textField.widthAnchor constraintEqualToConstant:240],
[textField.heightAnchor constraintEqualToConstant:AgentDeviceTextEntryFieldHeight],
]];
if (countsValueReads) {
if (countsValueReads || witnessesReplacement) {
AgentDeviceValueReadCountLabel *valueReads = [[AgentDeviceValueReadCountLabel alloc] init];
valueReads.countedField = textField;
valueReads.accessibilityIdentifier = @"agent-device-text-entry-value-reads";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#import <TargetConditionals.h>

#if TARGET_OS_IOS && defined(AGENT_DEVICE_RUNNER_UNIT_TESTS)
#import <UIKit/UIKit.h>

@interface AgentDeviceMaskedTextField : UITextField
@end

@interface AgentDeviceValueReadCountingTextField : AgentDeviceMaskedTextField
@end

@interface AgentDeviceValueReadCountLabel : UILabel
@property(nonatomic, weak) UITextField *countedField;
@end

@interface AgentDeviceTextEntryWitnessField : UITextField <UITextFieldDelegate, UIGestureRecognizerDelegate>
@end
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#import "AgentDeviceTextEntryFields.h"

#if TARGET_OS_IOS && defined(AGENT_DEVICE_RUNNER_UNIT_TESTS)
@implementation AgentDeviceMaskedTextField
- (NSString *)accessibilityValue {
NSUInteger length = self.text.length;
if (length == 0) {
return [super accessibilityValue];
}
NSUInteger bullets = MAX((NSUInteger)1, length / 2);
return [@"" stringByPaddingToLength:bullets withString:@"\u2022" startingAtIndex:0];
}
@end

static NSUInteger AgentDeviceTextEntryValueReads = 0;

@implementation AgentDeviceValueReadCountingTextField
- (NSString *)accessibilityValue {
AgentDeviceTextEntryValueReads += 1;
return [super accessibilityValue];
}
@end

@implementation AgentDeviceValueReadCountLabel
- (NSString *)accessibilityLabel {
if ([self.countedField isKindOfClass:[AgentDeviceTextEntryWitnessField class]]) {
return self.countedField.accessibilityHint;
}
return [NSString stringWithFormat:@"%lu", (unsigned long)AgentDeviceTextEntryValueReads];
}

- (NSString *)accessibilityValue {
return self.countedField.text;
}

@end

@interface AgentDeviceTextEntryWitnessField ()
@property(nonatomic, assign) NSUInteger taps;
@property(nonatomic, assign) NSUInteger selections;
@property(nonatomic, assign) NSUInteger rejectedEdits;
@property(nonatomic, assign) BOOL rejectNextSelectionDeletion;
@end

@implementation AgentDeviceTextEntryWitnessField
- (instancetype)init {
self = [super init];
if (self) {
self.delegate = self;
self.autocorrectionType = UITextAutocorrectionTypeNo;
self.autocapitalizationType = UITextAutocapitalizationTypeNone;
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(recordTap:)];
tap.cancelsTouchesInView = NO;
tap.delegate = self;
[self addGestureRecognizer:tap];
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-witness-prefill"]) {
self.text = @"seedvalue";
}
}
return self;
}

- (void)recordTap:(UITapGestureRecognizer *)recognizer {
(void)recognizer;
self.taps += 1;
}

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
(void)gestureRecognizer;
(void)otherGestureRecognizer;
return YES;
}

- (void)selectAll:(id)sender {
self.selections += 1;
self.rejectNextSelectionDeletion =
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-witness-refuse-selection"];
[super selectAll:sender];
}

- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string {
NSArray<NSString *> *arguments = NSProcessInfo.processInfo.arguments;
if ([arguments containsObject:@"--agent-device-text-entry-witness-refuse-edits"] ||
(self.rejectNextSelectionDeletion && range.length > 1 && string.length == 0)) {
self.rejectNextSelectionDeletion = NO;
self.rejectedEdits += 1;
UITextPosition *start = textField.beginningOfDocument;
textField.selectedTextRange = [textField textRangeFromPosition:start toPosition:start];
return NO;
}
return YES;
}

- (NSString *)accessibilityHint {
return [NSString stringWithFormat:@"taps=%lu selections=%lu rejected=%lu",
(unsigned long)self.taps, (unsigned long)self.selections, (unsigned long)self.rejectedEdits];
}
@end
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ extension RunnerTests {
let prefersFocusedElement: Bool
let contentTranslation: TextEntryContentTranslation
let fromTapWitness: Bool
let reusesCurrentFocus: Bool
/// The input the first resolved element was. Once bound, resolution refuses any other input.
let boundIdentity: TextEntryInputIdentity?
/// The text input that sat under `refreshPoint` before the focus tap, which binds the target to
Expand All @@ -179,6 +180,7 @@ extension RunnerTests {
prefersFocusedElement: Bool,
contentTranslation: TextEntryContentTranslation,
fromTapWitness: Bool = false,
reusesCurrentFocus: Bool = false,
boundIdentity: TextEntryInputIdentity? = nil,
inputAtRefreshPoint: TextInputAtPoint? = nil,
postFocusRead: ResolvedTextEntryInput? = nil
Expand All @@ -188,6 +190,7 @@ extension RunnerTests {
self.prefersFocusedElement = prefersFocusedElement
self.contentTranslation = contentTranslation
self.fromTapWitness = fromTapWitness
self.reusesCurrentFocus = reusesCurrentFocus
self.boundIdentity = boundIdentity ?? inputAtRefreshPoint?.identity
self.inputAtRefreshPoint = inputAtRefreshPoint
self.postFocusRead = postFocusRead
Expand All @@ -206,6 +209,7 @@ extension RunnerTests {
prefersFocusedElement: prefersFocusedElement,
contentTranslation: contentTranslation,
fromTapWitness: fromTapWitness,
reusesCurrentFocus: reusesCurrentFocus,
boundIdentity: boundIdentity ?? input.identity,
inputAtRefreshPoint: inputAtRefreshPoint
)
Expand Down Expand Up @@ -411,11 +415,13 @@ extension RunnerTests {
if existing?.isEmpty == true {
return
}
// Read before the caret tap, which dispatches to the app.
let defersCaretTap = reader.target.reusesCurrentFocus
var valueLength = normalizedElementText(input.attributes.value).count
// The tap also focuses the field, which a clear can reach with focus unconfirmed.
#if !os(tvOS)
try reader.dispatch { try moveCaretToEnd(app: app, input: input) }
if !defersCaretTap {
try reader.dispatch { try moveCaretToEnd(app: app, input: input) }
}
#endif
// An input that stops answering once focused (#3060) cannot be read back, so it gets one burst.
guard try reader.input() != nil else {
Expand Down Expand Up @@ -456,6 +462,19 @@ extension RunnerTests {
valueLength = remaining.count
}
#endif
if defersCaretTap {
guard let fallbackInput = try reader.input() else {
return
}
#if !os(tvOS)
try reader.dispatch { try moveCaretToEnd(app: app, input: fallbackInput) }
#endif
guard try reader.input() != nil else {
let burst = Self.textClearBurst(valueLength: valueLength)
reader.dispatch { fallbackInput.element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: burst)) }
return
}
}
for _ in 0...(valueLength / Self.maxTextClearBurst + 1) {
guard let input = try reader.input() else {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ extension RunnerTests {
}

@MainActor
func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) throws -> TextEntryTarget {
func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?, reusingFocusedInput: Bool = false) throws -> TextEntryTarget {
let contentTranslation = liveContentTranslation(app: app)
guard let x, let y else {
let softwareKeyboardVisible = isKeyboardVisible(app: app)
Expand Down Expand Up @@ -135,8 +135,23 @@ extension RunnerTests {
return TextEntryTarget(element: focused, refreshPoint: nil, prefersFocusedElement: true, contentTranslation: contentTranslation)
}

let keyboardVisibleBeforeTap = isKeyboardVisible(app: app)
let input = try textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y), contentTranslation: contentTranslation).first
#if os(iOS)
if reusingFocusedInput, let input, let identity = input.identity, identity.isDistinguishable,
let snapshot = input.snapshot, snapshotHasKeyboardFocus(snapshot) {
NSLog("%@current-input-reports-keyboard-focus polls=0", TextEntryFocusEvidence.logMarker)
return TextEntryTarget(
element: input.element,
refreshPoint: textEntryRefreshPoint(frame: input.frameOnScreen, fallbackTo: CGPoint(x: x, y: y)),
prefersFocusedElement: false,
contentTranslation: contentTranslation,
reusesCurrentFocus: true,
boundIdentity: identity,
postFocusRead: input
)
}
#endif
let keyboardVisibleBeforeTap = isKeyboardVisible(app: app)
let target = input?.element
let requestedPoint = CGPoint(x: x, y: y)
// `tapAt` takes a screen point: with no screen box, tap the caller's own point (#3899, #3060).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ extension RunnerTests {
if let resolvedCoordinateTarget {
target = resolvedCoordinateTarget
} else {
target = try focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
target = try focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y, reusingFocusedInput: textEntryMode == .replacement)
}
NSLog(
"AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE commandId=%@ phase=focus durationMs=%.1f chars=%d mode=%@",
Expand Down
Loading
Loading