@@ -110,6 +110,7 @@ @interface AgentDeviceRunnerViewController : UIViewController
110110@property (nonatomic , assign ) NSTimeInterval textEntryBurstMinGap;
111111@property (nonatomic , assign ) NSTimeInterval textEntryAcknowledgeWindowSeconds;
112112@property (nonatomic , strong ) NSLayoutConstraint *textEntryFieldTop;
113+ @property (nonatomic , weak ) UITextField *textEntryField;
113114@property (nonatomic , assign ) BOOL alertFixtureStarted;
114115@property (nonatomic , strong ) NSTimer *alertActivationBusyBackstop;
115116@property (nonatomic , strong ) NSTimer *alertBannerRepost;
@@ -130,6 +131,39 @@ - (NSString *)accessibilityValue {
130131}
131132@end
132133
134+ // XCTest reads every element's accessibility value each time it snapshots the app, which it does
135+ // for every element query and every attribute read from a live element. Counting this field's
136+ // value reads therefore counts the accessibility queries a command makes. The value is masked, so a
137+ // fill's read-back ends at its first read instead of polling through a stability window whose
138+ // poll count depends on how fast the host answers.
139+ static NSUInteger AgentDeviceTextEntryValueReads = 0 ;
140+
141+ @interface AgentDeviceValueReadCountingTextField : AgentDeviceMaskedTextField
142+ @end
143+
144+ @implementation AgentDeviceValueReadCountingTextField
145+ - (NSString *)accessibilityValue {
146+ AgentDeviceTextEntryValueReads += 1 ;
147+ return [super accessibilityValue ];
148+ }
149+ @end
150+
151+ // Reports the read count as its accessibility label and the counted field's text as its value, so
152+ // reading either changes no view state and the masked text stays checkable.
153+ @interface AgentDeviceValueReadCountLabel : UILabel
154+ @property (nonatomic , weak ) UITextField *countedField;
155+ @end
156+
157+ @implementation AgentDeviceValueReadCountLabel
158+ - (NSString *)accessibilityLabel {
159+ return [NSString stringWithFormat: @" %lu " , (unsigned long )AgentDeviceTextEntryValueReads];
160+ }
161+
162+ - (NSString *)accessibilityValue {
163+ return self.countedField .text ;
164+ }
165+ @end
166+
133167// A field that reformats its own text on every edit into `## ### ## ##` digit groups, the way a
134168// digit-grouping formatter in a Flutter or React Native input does. The accessibility value is
135169// what the user sees — the formatted string — so it never equals the digits a `fill` sent (#2634).
@@ -513,20 +547,33 @@ - (void)agentDeviceTextEntryDidChange:(UITextField *)textField {
513547 }
514548 if ((codeComplete && [arguments containsObject: @" --agent-device-text-entry-auto-submit" ]) ||
515549 (textField.text .length > 0 && [arguments containsObject: @" --agent-device-text-entry-replace-after-input" ])) {
516- UITextField *nextScreenField = [[UITextField alloc ] initWithFrame: textField.frame];
517- if (![arguments containsObject: @" --agent-device-text-entry-unnamed-input" ]) {
518- nextScreenField.accessibilityIdentifier = @" agent-device-auto-submit-next-screen-input" ;
519- }
520- nextScreenField.borderStyle = UITextBorderStyleRoundedRect;
521- [textField.superview addSubview: nextScreenField];
522- [textField removeFromSuperview ];
523- [nextScreenField becomeFirstResponder ];
550+ [self replaceTextEntryFieldWithNextScreenInput: textField];
524551 } else if (codeComplete &&
525552 [arguments containsObject: @" --agent-device-text-entry-auto-submit-without-successor" ]) {
526553 [textField removeFromSuperview ];
527554 }
528555}
529556
557+ - (void )replaceTextEntryFieldWithNextScreenInput : (UITextField *)textField {
558+ UITextField *nextScreenField = [[UITextField alloc ] initWithFrame: textField.frame];
559+ if (![NSProcessInfo .processInfo.arguments containsObject: @" --agent-device-text-entry-unnamed-input" ]) {
560+ nextScreenField.accessibilityIdentifier = @" agent-device-auto-submit-next-screen-input" ;
561+ }
562+ nextScreenField.borderStyle = UITextBorderStyleRoundedRect;
563+ [textField.superview addSubview: nextScreenField];
564+ [textField removeFromSuperview ];
565+ [nextScreenField becomeFirstResponder ];
566+ }
567+
568+ // The same navigation as `replace-after-input`, landing when a test presses the button instead of on
569+ // an edit, so the test decides which runner step it lands between.
570+ - (void )agentDeviceTextEntryReplaceDidPress : (UIButton *)sender {
571+ (void )sender;
572+ if (self.textEntryField .superview != nil ) {
573+ [self replaceTextEntryFieldWithNextScreenInput: self .textEntryField];
574+ }
575+ }
576+
530577// Stops answering the element-type query that found it, the moment it takes focus, under
531578// `--agent-device-text-entry-unqueryable-on-focus`. #3060's Flutter password field reaches this shape
532579// because its two accessibility channels disagree once focused — `TextField` through the legacy
@@ -690,6 +737,8 @@ - (void)viewDidLoad {
690737 [NSProcessInfo .processInfo.arguments containsObject: @" --agent-device-text-entry-submit-clears" ];
691738 BOOL maskedValue =
692739 [NSProcessInfo .processInfo.arguments containsObject: @" --agent-device-text-entry-masked-value" ];
740+ BOOL countsValueReads =
741+ [NSProcessInfo .processInfo.arguments containsObject: @" --agent-device-text-entry-counts-value-reads" ];
693742 UITextField *textField;
694743 if (digitCountValue) {
695744 textField = [[AgentDeviceDigitCountTextField alloc ] init ];
@@ -702,6 +751,8 @@ - (void)viewDidLoad {
702751 textField = [[AgentDeviceSubmitClearingTextField alloc ] init ];
703752 } else if (maskedValue) {
704753 textField = [[AgentDeviceMaskedTextField alloc ] init ];
754+ } else if (countsValueReads) {
755+ textField = [[AgentDeviceValueReadCountingTextField alloc ] init ];
705756 } else {
706757 textField = [[UITextField alloc ] init ];
707758 }
@@ -724,6 +775,7 @@ - (void)viewDidLoad {
724775 forControlEvents: UIControlEventEditingChanged];
725776 textField.translatesAutoresizingMaskIntoConstraints = NO ;
726777 [self .view addSubview: textField];
778+ self.textEntryField = textField;
727779 self.textEntryFieldTop = [textField.topAnchor constraintEqualToAnchor: label.bottomAnchor
728780 constant: AgentDeviceTextEntryFieldTopInset];
729781 [NSLayoutConstraint activateConstraints: @[
@@ -732,6 +784,18 @@ - (void)viewDidLoad {
732784 [textField.widthAnchor constraintEqualToConstant: 240 ],
733785 [textField.heightAnchor constraintEqualToConstant: AgentDeviceTextEntryFieldHeight],
734786 ]];
787+ if (countsValueReads) {
788+ AgentDeviceValueReadCountLabel *valueReads = [[AgentDeviceValueReadCountLabel alloc ] init ];
789+ valueReads.countedField = textField;
790+ valueReads.accessibilityIdentifier = @" agent-device-text-entry-value-reads" ;
791+ valueReads.text = @" value reads" ;
792+ valueReads.translatesAutoresizingMaskIntoConstraints = NO ;
793+ [self .view addSubview: valueReads];
794+ [NSLayoutConstraint activateConstraints: @[
795+ [valueReads.centerXAnchor constraintEqualToAnchor: self .view.centerXAnchor],
796+ [valueReads.topAnchor constraintEqualToAnchor: textField.bottomAnchor constant: 12 ],
797+ ]];
798+ }
735799 if (currencyValue || submitClears) {
736800 UILabel *entryStatus = [[UILabel alloc ] init ];
737801 entryStatus.accessibilityIdentifier = @" agent-device-text-entry-events" ;
@@ -774,6 +838,21 @@ - (void)viewDidLoad {
774838 ]];
775839 [self updateTapWitnessStatus ];
776840 }
841+ if ([NSProcessInfo .processInfo.arguments containsObject: @" --agent-device-text-entry-replace-on-request" ]) {
842+ // Above the title, so the software keyboard never covers it.
843+ UIButton *replace = [UIButton buttonWithType: UIButtonTypeSystem];
844+ replace.accessibilityIdentifier = @" agent-device-text-entry-replace" ;
845+ [replace setTitle: @" Replace input" forState: UIControlStateNormal];
846+ replace.translatesAutoresizingMaskIntoConstraints = NO ;
847+ [replace addTarget: self
848+ action: @selector (agentDeviceTextEntryReplaceDidPress: )
849+ forControlEvents: UIControlEventTouchUpInside];
850+ [self .view addSubview: replace];
851+ [NSLayoutConstraint activateConstraints: @[
852+ [replace.centerXAnchor constraintEqualToAnchor: self .view.centerXAnchor],
853+ [replace.bottomAnchor constraintEqualToAnchor: label.topAnchor constant: -24 ],
854+ ]];
855+ }
777856 if ([NSProcessInfo .processInfo.arguments containsObject: @" --agent-device-text-entry-unqueryable-on-focus" ]) {
778857 self.textEntryFocusWitness = [[UILabel alloc ] init ];
779858 self.textEntryFocusWitness .accessibilityIdentifier = @" agent-device-text-entry-focus" ;
0 commit comments