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 @@ -71,6 +71,7 @@ @interface AgentDeviceRunnerViewController : UIViewController
// took focus from an input that has stopped answering the accessibility query that found it.
@property(nonatomic, strong) UILabel *textEntryFocusWitness;
@property(nonatomic, assign) NSUInteger textEntryWriteBacks;
@property(nonatomic, assign) NSUInteger textEntryTotalEdits;
@property(nonatomic, copy, nullable) NSString *textEntryRenderedValue;
@property(nonatomic, assign) NSTimeInterval textEntryLastEditTime;
@property(nonatomic, assign) NSTimeInterval textEntryBurstStartTime;
Expand All @@ -97,6 +98,122 @@ - (NSString *)accessibilityValue {
return [NSString stringWithFormat:@"%lu of 6 digits", (unsigned long)self.text.length];
}
@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).
// The field owns the delegate it installs, keeping the formatter out of the view controller's
// own delegate callbacks.
@interface AgentDeviceDigitGroupingTextField : UITextField <UITextFieldDelegate>
@end

@interface AgentDeviceCurrencyTextField : UITextField <UITextFieldDelegate>
@property(nonatomic, strong) UILabel *entryStatus;
@property(nonatomic, assign) NSUInteger insertedDigits;
@end

@interface AgentDeviceSubmitClearingTextField : UITextField <UITextFieldDelegate>
@property(nonatomic, strong) UILabel *entryStatus;
@property(nonatomic, assign) NSUInteger submits;
@end

static NSString *AgentDeviceFormatGroupedDigits(NSString *raw) {
NSMutableString *digits = [NSMutableString string];
NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
for (NSString *chunk in [raw componentsSeparatedByCharactersInSet:nonDigits]) {
[digits appendString:chunk];
}
NSMutableArray<NSNumber *> *groupWidths = [NSMutableArray arrayWithArray:@[@2, @3, @2, @2]];
NSMutableString *formatted = [NSMutableString string];
NSUInteger cursor = 0;
for (NSNumber *width in groupWidths) {
if (cursor >= digits.length) {
break;
}
if (formatted.length > 0) {
[formatted appendString:@" "];
}
NSUInteger end = MIN(cursor + width.unsignedIntegerValue, digits.length);
[formatted appendString:[digits substringWithRange:NSMakeRange(cursor, end - cursor)]];
cursor = end;
}
// Digits past the last group keep grouping by threes rather than vanishing from the field.
while (cursor < digits.length) {
NSUInteger end = MIN(cursor + 3, digits.length);
[formatted appendString:[NSString stringWithFormat:@" %@", [digits substringWithRange:NSMakeRange(cursor, end - cursor)]]];
cursor = end;
}
return formatted;
}

static NSUInteger AgentDeviceDigitCount(NSString *text, NSUInteger limit) {
NSUInteger count = 0;
for (NSUInteger index = 0; index < limit && index < text.length; index++) {
if ([NSCharacterSet.decimalDigitCharacterSet characterIsMember:[text characterAtIndex:index]]) {
count += 1;
}
}
return count;
}

@implementation AgentDeviceDigitGroupingTextField
- (BOOL)textField:(UITextField *)textField
shouldChangeCharactersInRange:(NSRange)range
replacementString:(NSString *)string {
NSString *combined = [textField.text stringByReplacingCharactersInRange:range withString:string];
NSString *formatted = AgentDeviceFormatGroupedDigits(combined);
textField.text = formatted;
// Assigning `text` drops the caret at the end, which a real grouping formatter does not do:
// a mid-string edit must keep editing where the user put the caret. The digits before the
// caret survive the reformat unchanged, so the caret belongs before the digit that follows
// them — past the separators the grouping inserted behind them — and at the end when it
// followed all of them. Typed digits count as before the caret; a delete does not move it.
NSUInteger digitsBeforeCaret =
AgentDeviceDigitCount(textField.text, range.location) + AgentDeviceDigitCount(string, string.length);
NSUInteger caretOffset = formatted.length;
NSUInteger seen = 0;
for (NSUInteger index = 0; index < formatted.length; index++) {
if (![NSCharacterSet.decimalDigitCharacterSet characterIsMember:[formatted characterAtIndex:index]]) {
continue;
}
seen += 1;
if (seen == digitsBeforeCaret + 1) {
caretOffset = index;
break;
}
}
textField.selectedTextRange = [textField textRangeFromPosition:[textField positionFromPosition:textField.beginningOfDocument offset:caretOffset]
toPosition:[textField positionFromPosition:textField.beginningOfDocument offset:caretOffset]];
return NO;
}
@end

@implementation AgentDeviceCurrencyTextField
- (BOOL)textField:(UITextField *)textField
shouldChangeCharactersInRange:(NSRange)range
replacementString:(NSString *)string {
NSString *combined = [textField.text stringByReplacingCharactersInRange:range withString:string];
NSMutableString *digits = [NSMutableString string];
for (NSString *part in [combined componentsSeparatedByCharactersInSet:NSCharacterSet.decimalDigitCharacterSet.invertedSet]) {
[digits appendString:part];
}
unsigned long long cents = digits.longLongValue;
textField.text = [NSString stringWithFormat:@"$%llu.%02llu", cents / 100, cents % 100];
self.insertedDigits += AgentDeviceDigitCount(string, string.length);
self.entryStatus.text = [NSString stringWithFormat:@"%lu", (unsigned long)self.insertedDigits];
textField.selectedTextRange = [textField textRangeFromPosition:textField.endOfDocument toPosition:textField.endOfDocument];
return NO;
}
@end

@implementation AgentDeviceSubmitClearingTextField
- (BOOL)textFieldShouldReturn:(UITextField *)textField {
self.submits += 1;
self.entryStatus.text = [NSString stringWithFormat:@"%lu", (unsigned long)self.submits];
textField.text = @"";
return YES;
}
@end
#endif

@implementation AgentDeviceRunnerViewController
Expand Down Expand Up @@ -202,11 +319,12 @@ - (void)updateAlertActionStatus {
- (void)updateTextEntryWriteBackStatus {
NSTimeInterval burstSpan = self.textEntryLastEditTime - self.textEntryBurstStartTime;
self.textEntryWriteBackStatus.text = [NSString
stringWithFormat:@"write-backs=%lu burst-edits=%lu burst-ms=%lu min-gap-ms=%lu",
stringWithFormat:@"write-backs=%lu burst-edits=%lu burst-ms=%lu min-gap-ms=%lu total-edits=%lu",
(unsigned long)self.textEntryWriteBacks,
(unsigned long)self.textEntryBurstEdits,
(unsigned long)llround(burstSpan * 1000),
(unsigned long)llround(self.textEntryBurstMinGap * 1000)];
(unsigned long)llround(self.textEntryBurstMinGap * 1000),
(unsigned long)self.textEntryTotalEdits];
}

- (void)presentAlertFixtureReplacement:(BOOL)replacement {
Expand Down Expand Up @@ -292,6 +410,7 @@ - (void)agentDeviceTextEntryDidChange:(UITextField *)textField {
// typed faster than the app renders loses the characters that arrived while a render was in
// flight, and the field settles stable short of the request.
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-app-owned-value"]) {
self.textEntryTotalEdits += 1;
NSTimeInterval now = NSProcessInfo.processInfo.systemUptime;
NSTimeInterval gap = now - self.textEntryLastEditTime;
BOOL overtookARender = self.textEntryRenderedValue != nil && gap < self.textEntryAcknowledgeWindowSeconds;
Expand Down Expand Up @@ -408,8 +527,28 @@ - (void)viewDidLoad {
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-regression"]) {
BOOL digitCountValue =
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-digit-count-value"];
UITextField *textField =
digitCountValue ? [[AgentDeviceDigitCountTextField alloc] init] : [[UITextField alloc] init];
BOOL digitGroupingValue =
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-digit-grouping-value"];
BOOL currencyValue =
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-currency-value"];
BOOL submitClears =
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-submit-clears"];
UITextField *textField;
if (digitCountValue) {
textField = [[AgentDeviceDigitCountTextField alloc] init];
} else if (digitGroupingValue) {
textField = [[AgentDeviceDigitGroupingTextField alloc] init];
} else if (currencyValue) {
textField = [[AgentDeviceCurrencyTextField alloc] init];
textField.text = @"$0.00";
} else if (submitClears) {
textField = [[AgentDeviceSubmitClearingTextField alloc] init];
} else {
textField = [[UITextField alloc] init];
}
if (digitGroupingValue || currencyValue || submitClears) {
textField.delegate = (id<UITextFieldDelegate>)textField;
}
if (![NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-unnamed-input"]) {
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";
}
Expand All @@ -434,6 +573,22 @@ - (void)viewDidLoad {
[textField.widthAnchor constraintEqualToConstant:240],
[textField.heightAnchor constraintEqualToConstant:AgentDeviceTextEntryFieldHeight],
]];
if (currencyValue || submitClears) {
UILabel *entryStatus = [[UILabel alloc] init];
entryStatus.accessibilityIdentifier = @"agent-device-text-entry-events";
entryStatus.text = @"0";
entryStatus.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:entryStatus];
[NSLayoutConstraint activateConstraints:@[
[entryStatus.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
[entryStatus.topAnchor constraintEqualToAnchor:textField.bottomAnchor constant:12],
]];
if (currencyValue) {
((AgentDeviceCurrencyTextField *)textField).entryStatus = entryStatus;
} else {
((AgentDeviceSubmitClearingTextField *)textField).entryStatus = entryStatus;
}
}
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-unqueryable-on-focus"]) {
self.textEntryFocusWitness = [[UILabel alloc] init];
self.textEntryFocusWitness.accessibilityIdentifier = @"agent-device-text-entry-focus";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,11 +522,8 @@ extension RunnerTests {
/// `awaitSynthesizedReplacementCommit` binds the real XCUI reads). The budget defaults to the
/// shipped one, so only a test that is asking about time has to name it.
///
/// Only an exact match settles. A value with a hole in the middle ("ada@example" -> "aexample")
/// is the corruption this wait exists to catch, and `.replacement` mode has no formatter or
/// autocomplete carve-out: `isRepairableTextEntryMismatch` (RunnerTests+TextTyping.swift) treats
/// every `.replacement` mismatch as repairable, because `fill` owns the whole field via
/// select-all. A settled non-match is therefore always the wait's failure case.
/// This route has no element-bound baseline for unconfirmed evidence. Only exact read-back
/// confirms a commit; prefix progress extends the bounded wait without confirming the value.
static func awaitSynthesizedReplacementCommitOutcome(
expectedText: String,
placeholder: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,8 @@ extension RunnerTests {
static let pollInterval: TimeInterval = 0.02
static let warmupValueTimeout: TimeInterval = 0.4
static let verificationStabilityWindow: TimeInterval = 0.2
/// How long a replacement value may take to move off its baseline, and then how long an unechoed
/// value may keep changing before it must hold for `verificationStabilityWindow`; the app renders
/// the last characters' summary late.
static let unconfirmedSettleCeiling: TimeInterval = 1.0
/// Initial replacement observation budget; a late first read gets one stability window.
static let replacementSettleCeiling: TimeInterval = 1.0
/// How long the commit wait tolerates seeing NO further progress toward the expected value.
/// Numerically the flat deadline this replaced, so a pipeline that delivers nothing is
/// condemned at exactly the same instant it always was (see `SynthesizedCommitDeadline`).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import XCTest

// What a replacement's read-back can prove when the field's value does not echo the typed text.
// Target-bound observations and the replacement read-back policy.
// The wire shape mirrors the cross-platform `FillUnconfirmedVerification` in
// packages/contracts/src/fill-evidence.ts.
extension RunnerTests {
Expand Down Expand Up @@ -39,41 +39,74 @@ extension RunnerTests {
let target: TextEntryElementIdentity
}

/// Whether `observed` can be a degraded copy of the residual `baseline` plus the request: it is in
/// order inside baseline + request (dropped characters), or it contains the request in order and
/// the baseline did not already (residual text), or it contains baseline + request in order. A
/// value related to the entry in none of these ways, such as an OTP field announcing
/// "6 of 6 digits", is the app's own representation, so retyping cannot make it match.
static func textEntryValueEchoes(observed: String, expected: String, baseline: String) -> Bool {
let request = textEntryRequestWithoutSubmitKeys(expected)
let residualAndRequest = baseline + request
return isOrderedSubsequence(observed, of: residualAndRequest)
|| (isOrderedSubsequence(request, of: observed) && !isOrderedSubsequence(request, of: baseline))
|| isOrderedSubsequence(residualAndRequest, of: observed)
struct ReplacementTextEntryConfirmation {
private let requested: String
private let baseline: TextEntryObservation?
private var deadline: Date
private var latest: TextEntryObservation?
private var stableSince: Date

init(requested: String, baseline: TextEntryObservation?, startedAt: Date) {
self.requested = requested
self.baseline = baseline
deadline = startedAt.addingTimeInterval(TextEntryTiming.replacementSettleCeiling)
stableSince = startedAt
}

mutating func observe(_ observed: TextEntryObservation, at sampledAt: Date) -> TextEntryResult? {
// A late first read still needs one stability window.
if latest == nil {
deadline = max(deadline, sampledAt.addingTimeInterval(TextEntryTiming.verificationStabilityWindow))
}
if latest.map({ observed.isSettled(with: $0) }) != true { stableSince = sampledAt }
latest = observed
let result = RunnerTests.replacementTextEntryResult(requested: requested, baseline: baseline, observed: observed)
let settled = sampledAt.timeIntervalSince(stableSince) >= TextEntryTiming.verificationStabilityWindow
let moved = baseline.map { !observed.isSettled(with: $0) } ?? true
if settled && (moved || result.verified != false || sampledAt >= deadline) {
return result
}
if sampledAt >= deadline {
return TextEntryResult(
verified: nil, repaired: false, expectedText: requested, observedText: observed.value,
failure: .commitNotObserved
)
}
return nil
}
}

/// Classifies a replacement whose read-back never matched. The entry is unconfirmed, not failed,
/// only when the same element's value moved off its pre-entry baseline to one that does not echo
/// the request; every other mismatch stays a failure.
static func unconfirmedTextEntryEvidence(
/// Literal read-back policy; a mismatch does not establish why the app changed the text.
static func replacementTextEntryResult(
requested: String,
baseline: TextEntryObservation?,
observed: TextEntryObservation?
) -> TextEntryUnconfirmedEvidence? {
guard !textEntryRequestWithoutSubmitKeys(requested).isEmpty,
let baseline,
let observed,
baseline.identity.isSameElement(as: observed.identity),
observed.value != baseline.value,
!textEntryValueEchoes(observed: observed.value, expected: requested, baseline: baseline.value)
else {
return nil
) -> TextEntryResult {
func result(_ verified: Bool?) -> TextEntryResult {
TextEntryResult(verified: verified, repaired: false, expectedText: requested, observedText: observed?.value)
}
guard let observed else { return result(nil) }
if let baseline, !baseline.identity.isSameElement(as: observed.identity) {
return result(false)
}
return TextEntryUnconfirmedEvidence(
requested: requested,
before: baseline.value,
after: observed.value,
target: observed.identity
let request = observed.identity.elementType == elementTypeNamesByRawValue[XCUIElement.ElementType.textView.rawValue]
? requested : textEntryRequestWithoutSubmitKeys(requested)
if observed.value == requested || observed.value == request { return result(true) }
// Any non-matching single-line submit result remains unverified, even a partial value.
if request != requested { return result(nil) }
Comment thread
thymikee marked this conversation as resolved.
guard !request.isEmpty, let baseline else { return result(false) }
if isOrderedSubsequence(observed.value, of: baseline.value + request) { return result(false) }
return TextEntryResult(
verified: nil,
repaired: false,
expectedText: requested,
observedText: observed.value,
unconfirmed: TextEntryUnconfirmedEvidence(
requested: requested,
before: baseline.value,
after: observed.value,
target: observed.identity
)
)
}

Expand Down
Loading
Loading