Skip to content

Commit c332214

Browse files
committed
fix(ios): stop fill from repairing fields that normalize their input (#2634)
Replacement-mode entry into a field that reformats what it holds (digit grouping, masks, currency) always ran the destructive clear-and-retype against an already-correct field and then failed with TEXT_ENTRY_MISMATCH: isRepairableTextEntryMismatch returned true for every replacement mismatch, so the dropped-character classifier below it never ran. The gate now asks the same question in both modes: only an empty field or a short in-order cut of the request is something a retype can undo. A value that carries every requested character in order, which raw equality can never reach because the app inserted formatting around it, now reaches the existing unconfirmed-evidence path instead of failing. The choice is deliberate and named: textEntryValueEchoes is UNCHANGED — a complete mask value stays an echo, because echo answers whether the value COULD be a degraded copy of baseline + request, and the phone-mask pin in testDegradedEchoesStayEchoesAndSummariesDoNot still stands. What changes is what an echo withholds: evidence is now refused only when the value could be degraded AND cannot be complete, judged by ordered containment of a non-empty request the baseline did not already carry (textEntryValueCarriesRequest). The adversarial pair separates on that containment, not on a separator dictionary: '00 062 91 77' carries '000629177', while '00 62 91 77' and '00 026 91 77' do not. No new outcome shape, no wire change, and the field is typed exactly once. Fixture: AgentDeviceDigitGroupingTextField reproduces the reported '## ### ## ##' formatter; the device test observes the runner log go from REPAIR_TEXT_ENTRY expectedLength=9 observedLength=12 to TEXT_ENTRY_UNCONFIRMED repaired=0, and the classifier tables pin the boundary at the deciding predicate.
1 parent 24b2ce6 commit c332214

8 files changed

Lines changed: 270 additions & 28 deletions

File tree

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

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,53 @@ - (NSString *)accessibilityValue {
9797
return [NSString stringWithFormat:@"%lu of 6 digits", (unsigned long)self.text.length];
9898
}
9999
@end
100+
101+
// A field that reformats its own text on every edit into `## ### ## ##` digit groups, the way a
102+
// digit-grouping formatter in a Flutter or React Native input does. The accessibility value is
103+
// what the user sees — the formatted string — so it never equals the digits a `fill` sent (#2634).
104+
// The field owns the delegate it installs, keeping the formatter out of the view controller's
105+
// own delegate callbacks.
106+
@interface AgentDeviceDigitGroupingTextField : UITextField <UITextFieldDelegate>
107+
@end
108+
109+
static NSString *AgentDeviceFormatGroupedDigits(NSString *raw) {
110+
NSMutableString *digits = [NSMutableString string];
111+
NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
112+
for (NSString *chunk in [raw componentsSeparatedByCharactersInSet:nonDigits]) {
113+
[digits appendString:chunk];
114+
}
115+
NSMutableArray<NSNumber *> *groupWidths = [NSMutableArray arrayWithArray:@[@2, @3, @2, @2]];
116+
NSMutableString *formatted = [NSMutableString string];
117+
NSUInteger cursor = 0;
118+
for (NSNumber *width in groupWidths) {
119+
if (cursor >= digits.length) {
120+
break;
121+
}
122+
if (formatted.length > 0) {
123+
[formatted appendString:@" "];
124+
}
125+
NSUInteger end = MIN(cursor + width.unsignedIntegerValue, digits.length);
126+
[formatted appendString:[digits substringWithRange:NSMakeRange(cursor, end - cursor)]];
127+
cursor = end;
128+
}
129+
// Digits past the last group keep grouping by threes rather than vanishing from the field.
130+
while (cursor < digits.length) {
131+
NSUInteger end = MIN(cursor + 3, digits.length);
132+
[formatted appendString:[NSString stringWithFormat:@" %@", [digits substringWithRange:NSMakeRange(cursor, end - cursor)]]];
133+
cursor = end;
134+
}
135+
return formatted;
136+
}
137+
138+
@implementation AgentDeviceDigitGroupingTextField
139+
- (BOOL)textField:(UITextField *)textField
140+
shouldChangeCharactersInRange:(NSRange)range
141+
replacementString:(NSString *)string {
142+
NSString *combined = [textField.text stringByReplacingCharactersInRange:range withString:string];
143+
textField.text = AgentDeviceFormatGroupedDigits(combined);
144+
return NO;
145+
}
146+
@end
100147
#endif
101148

102149
@implementation AgentDeviceRunnerViewController
@@ -408,8 +455,20 @@ - (void)viewDidLoad {
408455
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-regression"]) {
409456
BOOL digitCountValue =
410457
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-digit-count-value"];
411-
UITextField *textField =
412-
digitCountValue ? [[AgentDeviceDigitCountTextField alloc] init] : [[UITextField alloc] init];
458+
BOOL digitGroupingValue =
459+
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-digit-grouping-value"];
460+
UITextField *textField;
461+
if (digitCountValue) {
462+
textField = [[AgentDeviceDigitCountTextField alloc] init];
463+
} else if (digitGroupingValue) {
464+
textField = [[AgentDeviceDigitGroupingTextField alloc] init];
465+
} else {
466+
textField = [[UITextField alloc] init];
467+
}
468+
if (digitGroupingValue) {
469+
// The formatter field is its own delegate: every edit is reformatted before it lands.
470+
textField.delegate = (id<UITextFieldDelegate>)textField;
471+
}
413472
if (![NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-unnamed-input"]) {
414473
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";
415474
}

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -523,10 +523,11 @@ extension RunnerTests {
523523
/// shipped one, so only a test that is asking about time has to name it.
524524
///
525525
/// Only an exact match settles. A value with a hole in the middle ("ada@example" -> "aexample")
526-
/// is the corruption this wait exists to catch, and `.replacement` mode has no formatter or
527-
/// autocomplete carve-out: `isRepairableTextEntryMismatch` (RunnerTests+TextTyping.swift) treats
528-
/// every `.replacement` mismatch as repairable, because `fill` owns the whole field via
529-
/// select-all. A settled non-match is therefore always the wait's failure case.
526+
/// is the corruption this wait exists to catch, and the repair gate
527+
/// `isRepairableTextEntryMismatch` (RunnerTests+TextTyping.swift) accepts only an empty field or
528+
/// a short in-order cut of the request, so a field longer than the request — one that normalizes
529+
/// what it holds (#2634) — is never retyped either. A settled non-match is therefore always the
530+
/// wait's failure case.
530531
static func awaitSynthesizedReplacementCommitOutcome(
531532
expectedText: String,
532533
placeholder: String?,

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,25 @@ extension RunnerTests {
5252
|| isOrderedSubsequence(residualAndRequest, of: observed)
5353
}
5454

55+
/// Whether `observed` shows every character of the request, in order, at a field whose baseline
56+
/// did not already show them: the requested text with app-owned insertions around it. This is the
57+
/// shape a field that normalizes its content presents — a `## ### ## ##` digit grouping of the
58+
/// typed digits, a phone or currency mask (#2634). The value carries nothing out of order and
59+
/// drops nothing, and a retype posts the same characters through the same formatter, so no
60+
/// repair can bring it closer to raw equality; the fill is unconfirmed with target-bound
61+
/// evidence, not failed.
62+
static func textEntryValueCarriesRequest(observed: String, expected: String, baseline: String) -> Bool {
63+
let request = textEntryRequestWithoutSubmitKeys(expected)
64+
guard !request.isEmpty, !isOrderedSubsequence(request, of: baseline) else {
65+
return false
66+
}
67+
return isOrderedSubsequence(request, of: observed)
68+
}
69+
5570
/// Classifies a replacement whose read-back never matched. The entry is unconfirmed, not failed,
5671
/// only when the same element's value moved off its pre-entry baseline to one that does not echo
57-
/// the request; every other mismatch stays a failure.
72+
/// the request, or that carries the whole request in order a normalizing field can never render
73+
/// raw (#2634); every other mismatch stays a failure.
5874
static func unconfirmedTextEntryEvidence(
5975
requested: String,
6076
baseline: TextEntryObservation?,
@@ -66,6 +82,11 @@ extension RunnerTests {
6682
baseline.identity.isSameElement(as: observed.identity),
6783
observed.value != baseline.value,
6884
!textEntryValueEchoes(observed: observed.value, expected: requested, baseline: baseline.value)
85+
|| textEntryValueCarriesRequest(
86+
observed: observed.value,
87+
expected: requested,
88+
baseline: baseline.value
89+
)
6990
else {
7091
return nil
7192
}

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

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,6 @@ extension RunnerTests {
346346
app: app,
347347
target: activeTarget,
348348
expectedText: expectedText,
349-
repairMode: repairMode,
350349
baseline: entryBaseline
351350
)
352351
logTextEntryPhase(
@@ -393,7 +392,6 @@ extension RunnerTests {
393392
app: XCUIApplication,
394393
target: TextEntryTarget,
395394
expectedText: String?,
396-
repairMode: TextTypingRepairMode,
397395
baseline: TextEntryObservation?
398396
) -> TextEntryResult {
399397
if target.boundIdentity != nil, resolveTextEntryElement(app: app, target: target) == nil {
@@ -436,8 +434,7 @@ extension RunnerTests {
436434
guard shouldRepairTextEntry(
437435
app: app,
438436
target: target,
439-
expectedText: expectedText,
440-
repairMode: repairMode
437+
expectedText: expectedText
441438
) else {
442439
return verifyTextEntry(
443440
app: app,
@@ -634,8 +631,7 @@ extension RunnerTests {
634631
private func shouldRepairTextEntry(
635632
app: XCUIApplication,
636633
target: TextEntryTarget,
637-
expectedText: String,
638-
repairMode: TextTypingRepairMode
634+
expectedText: String
639635
) -> Bool {
640636
#if os(iOS)
641637
var latestObservedText: String?
@@ -652,10 +648,9 @@ extension RunnerTests {
652648
return false
653649
}
654650
latestObservedText = observedText
655-
if !isRepairableTextEntryMismatch(
651+
if !Self.isRepairableTextEntryMismatch(
656652
observedText: observedText,
657-
expectedText: expectedText,
658-
repairMode: repairMode
653+
expectedText: expectedText
659654
) {
660655
return false
661656
}
@@ -672,34 +667,37 @@ extension RunnerTests {
672667
) else {
673668
return false
674669
}
675-
return isRepairableTextEntryMismatch(
670+
return Self.isRepairableTextEntryMismatch(
676671
observedText: latestObservedText,
677-
expectedText: expectedText,
678-
repairMode: repairMode
672+
expectedText: expectedText
679673
)
680674
#else
681675
return false
682676
#endif
683677
}
684678

685-
private func isRepairableTextEntryMismatch(
679+
/// Whether clearing and retyping can still make the field answer with the request. Only two
680+
/// shapes qualify: a field that reads EMPTY after entry (the text never arrived, or the app
681+
/// swallowed it, and a retype is the only way to learn which), and a value that is a short,
682+
/// in-order cut of the request (the dropped characters a too-fast burst produces). A field whose
683+
/// value is LONGER than the request is not corrupted by entry: a field that normalizes what it
684+
/// holds ("00 062 91 77" for "000629177", #2634) reaches this classifier with the complete,
685+
/// formatted value, and the destructive clear-plus-retype would only mutate a correct field
686+
/// before failing with the same mismatch it started on.
687+
static func isRepairableTextEntryMismatch(
686688
observedText: String,
687-
expectedText: String,
688-
repairMode: TextTypingRepairMode
689+
expectedText: String
689690
) -> Bool {
690691
guard observedText != expectedText else {
691692
return false
692693
}
693-
if repairMode == .replacement {
694-
return true
695-
}
696694
return observedText.isEmpty || isLikelyDroppedCharacterTextEntryMismatch(
697695
observedText: observedText,
698696
expectedText: expectedText
699697
)
700698
}
701699

702-
private func isLikelyDroppedCharacterTextEntryMismatch(observedText: String, expectedText: String) -> Bool {
700+
static func isLikelyDroppedCharacterTextEntryMismatch(observedText: String, expectedText: String) -> Bool {
703701
guard observedText.count < expectedText.count else {
704702
return false
705703
}

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

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,99 @@ extension RunnerTests {
6262
XCTAssertTrue(Self.textEntryValueEchoes(observed: "abc123abc12", expected: "abc123", baseline: "abc123"))
6363
}
6464

65+
// The #2634 decision, named. `textEntryValueEchoes` and its table above are UNCHANGED: a
66+
// complete mask value is still an echo, because "echo" answers whether the value COULD be a
67+
// degraded copy of baseline + request. What changes is what an echo withholds. Withholding
68+
// evidence on echo alone rested on an equation — could-be-degraded implies cannot-be-complete —
69+
// that is false: "00 062 91 77" for "000629177" is an echo AND the whole request with the
70+
// formatter's separators in it, and no retype posts that value closer to raw equality. So
71+
// evidence is now refused only when the value could be degraded AND cannot be complete: the
72+
// request's length lower-bounds any value that carries it in order, which keeps the
73+
// separator-free cut (`12456` for `123456`) refused without a separator dictionary. A cut
74+
// DEEPER than the drop budget was never repairable either, and a separator-bearing mismatch
75+
// already reached evidence before this change — what evidence must never do is claim the
76+
// value correct; only `textEntryValueMatchesExpected` verifies, and it is untouched.
77+
func testACompleteFormattedValueReachesUnconfirmedWhileAConfirmedCutStaysAFailure() {
78+
let grouping = Self.unconfirmedTextEntryEvidence(
79+
requested: "000629177",
80+
baseline: Self.otpObservation(""),
81+
observed: Self.otpObservation("00 062 91 77")
82+
)
83+
XCTAssertEqual(grouping?.after, "00 062 91 77")
84+
85+
// The mask pinned as an echo above now reaches evidence too, when the same identified field
86+
// moved off its post-clear baseline: "(555) 123-4567" carries all ten requested digits.
87+
let phone = Self.unconfirmedTextEntryEvidence(
88+
requested: "5551234567",
89+
baseline: Self.otpObservation(""),
90+
observed: Self.otpObservation("(555) 123-4567")
91+
)
92+
XCTAssertEqual(phone?.after, "(555) 123-4567")
93+
94+
// A stale prefix that carries the WHOLE request is evidence too: post-clear, the prefix is
95+
// the app's own text, and `before`/`after` show the caller exactly that.
96+
let prefixed = Self.unconfirmedTextEntryEvidence(
97+
requested: "123456",
98+
baseline: Self.otpObservation(""),
99+
observed: Self.otpObservation("old123456")
100+
)
101+
XCTAssertEqual(prefixed?.after, "old123456")
102+
103+
// The refusal this clause must not soften: a separator-free ordered cut is shorter than the
104+
// request, so it cannot carry it, and it still echoes the residual — no evidence.
105+
XCTAssertNil(
106+
Self.unconfirmedTextEntryEvidence(
107+
requested: "123456",
108+
baseline: Self.otpObservation(""),
109+
observed: Self.otpObservation("12456")
110+
)
111+
)
112+
// A value that never moved is still no evidence, however short of the request it sits.
113+
XCTAssertNil(
114+
Self.unconfirmedTextEntryEvidence(
115+
requested: "123456",
116+
baseline: Self.otpObservation(""),
117+
observed: Self.otpObservation("")
118+
)
119+
)
120+
// The baseline already carrying the request keeps the old reading: the entry proved nothing,
121+
// so there is no evidence to hand back.
122+
XCTAssertNil(
123+
Self.unconfirmedTextEntryEvidence(
124+
requested: "123456",
125+
baseline: Self.otpObservation("(555) 123-4567"),
126+
observed: Self.otpObservation("5551234567")
127+
)
128+
)
129+
}
130+
131+
// The completeness predicate's own table, paired positive/negative per the boundary this
132+
// decision stands on. Carrying the request is ordered containment of a NON-EMPTY request the
133+
// baseline did not already carry, so the separators a formatter inserts are irrelevant and the
134+
// refusals are arithmetic, not a separator dictionary: a dropped character makes the value
135+
// shorter than the request it claims to carry, and a reordering is a drop plus a re-insertion.
136+
// The predicate never claims correctness — only `textEntryValueMatchesExpected` verifies — so
137+
// its whole duty is refusing an incomplete value; that is what this table holds it to.
138+
func testOnlyAValueContainingTheWholeFreshRequestCarriesIt() {
139+
XCTAssertTrue(Self.textEntryValueCarriesRequest(observed: "00 062 91 77", expected: "000629177", baseline: ""))
140+
XCTAssertTrue(Self.textEntryValueCarriesRequest(observed: "(555) 123-4567", expected: "5551234567", baseline: ""))
141+
XCTAssertTrue(Self.textEntryValueCarriesRequest(observed: "R $12.00", expected: "R12.00", baseline: ""))
142+
// One dropped digit: eight characters cannot contain a nine-character request.
143+
XCTAssertFalse(Self.textEntryValueCarriesRequest(observed: "00 62 91 77", expected: "000629177", baseline: ""))
144+
XCTAssertFalse(Self.textEntryValueCarriesRequest(observed: "12456", expected: "123456", baseline: ""))
145+
// A reordering: the request appears in the value out of order.
146+
XCTAssertFalse(Self.textEntryValueCarriesRequest(observed: "00 026 91 77", expected: "000629177", baseline: ""))
147+
// An unrelated or degraded-echo value does not carry the request either way.
148+
XCTAssertFalse(Self.textEntryValueCarriesRequest(observed: "6 of 6 digits", expected: "123456", baseline: ""))
149+
XCTAssertFalse(Self.textEntryValueCarriesRequest(observed: "", expected: "123456", baseline: ""))
150+
// The empty and already-contained requests cannot be proven by any value: the entry moved
151+
// nothing new into the field.
152+
XCTAssertFalse(Self.textEntryValueCarriesRequest(observed: "anything", expected: "", baseline: ""))
153+
XCTAssertFalse(Self.textEntryValueCarriesRequest(observed: "5551234567", expected: "123456", baseline: "(555) 123-4567"))
154+
// A submit key is pressed, not typed, so it must not sink the containment check.
155+
XCTAssertTrue(Self.textEntryValueCarriesRequest(observed: "(555) 123-4567", expected: "5551234567\n", baseline: ""))
156+
}
157+
65158
func testEveryOtherReplacementMismatchStaysAFailure() {
66159
let baseline = Self.otpObservation("0 of 6 digits")
67160
let otherField = TextEntryElementIdentity(
@@ -133,6 +226,61 @@ extension RunnerTests {
133226
try assertDigitCountFillIsUnconfirmed(code: "6", summary: "1 of 6 digits")
134227
}
135228

229+
/// #2634: a field that reformats what it holds, the way a `## ### ## ##` digit-grouping formatter
230+
/// does. Every requested digit arrives, so the fill succeeds with target-bound evidence instead of
231+
/// failing on a value raw equality can never reach — and, the reported half, the runner does not
232+
/// clear and retype the field it was asked to fill. The response message proves that: a repair
233+
/// would report "typed after repair".
234+
@MainActor
235+
func testFillIntoDigitGroupingFieldReportsUnconfirmedEvidenceAndDoesNotRepair() throws {
236+
app.launchArguments = [
237+
"--agent-device-text-entry-regression",
238+
"--agent-device-text-entry-digit-grouping-value",
239+
]
240+
app.launch()
241+
addTeardownBlock { [self] in
242+
invalidateCachedTarget(reason: "unit_test_cleanup")
243+
app.terminate()
244+
}
245+
let textField = app.textFields["agent-device-hardware-keyboard-input"]
246+
XCTAssertTrue(textField.waitForExistence(timeout: appExistenceTimeout))
247+
let frame = textField.frame
248+
let command = try JSONDecoder().decode(
249+
Command.self,
250+
from: JSONSerialization.data(withJSONObject: [
251+
"command": "type",
252+
"commandId": "fill-digit-grouping",
253+
"text": "000629177",
254+
"textEntryMode": "replace",
255+
"x": frame.midX,
256+
"y": frame.midY,
257+
"appBundleId": "com.callstack.agentdevice.runner",
258+
])
259+
)
260+
261+
let failuresBeforeType = currentXCTestFailureCount()
262+
let response = executeTypeCommand(activeApp: app, command: command)
263+
264+
XCTAssertFalse(didRecordXCTestFailure(since: failuresBeforeType))
265+
XCTAssertTrue(response.ok, String(describing: response.error))
266+
XCTAssertEqual(response.data?.message, "typed")
267+
XCTAssertEqual(response.data?.verification, "unconfirmed")
268+
XCTAssertEqual(response.data?.requested, "000629177")
269+
XCTAssertEqual(response.data?.before, "")
270+
XCTAssertEqual(response.data?.after, "00 062 91 77")
271+
XCTAssertEqual(response.data?.target?.resourceId, "agent-device-hardware-keyboard-input")
272+
// The field is left holding the digits the request carried, grouped: neither cleared out nor
273+
// doubled by a retype.
274+
let deadline = Date().addingTimeInterval(appExistenceTimeout)
275+
var observed: String?
276+
while Date() < deadline {
277+
observed = textField.value as? String
278+
if observed == "00 062 91 77" { break }
279+
Thread.sleep(forTimeInterval: 0.25)
280+
}
281+
XCTAssertEqual(observed, "00 062 91 77")
282+
}
283+
136284
/// Fills the digit-count fixture field with `code` and asserts unconfirmed evidence and one entry.
137285
@MainActor
138286
private func assertDigitCountFillIsUnconfirmed(code: String, summary: String) throws {

0 commit comments

Comments
 (0)