Skip to content

Commit 1db735e

Browse files
committed
refactor(ios): isolate synthesized text entry
1 parent ac33ef0 commit 1db735e

5 files changed

Lines changed: 276 additions & 271 deletions

File tree

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.m

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
RunnerSynthesizedTextEntryStatus status,
1818
NSString * _Nullable message
1919
);
20+
static RunnerSynthesizedTextEntryResult *RunnerTextEntryUnavailable(NSString *message);
21+
static RunnerSynthesizedTextEntryResult *RunnerTextEntryFailed(NSString *message);
2022
static RunnerSynthesizedTextEntryResult *RunnerSynthesizeTextWithMode(
2123
id application,
2224
NSString *text,
@@ -66,37 +68,35 @@ + (RunnerSynthesizedTextEntryResult *)replaceTextWithApplication:(id)application
6668
SEL processID = NSSelectorFromString(@"processID");
6769

6870
NSString *missing = RunnerRequireTextClass(recordClass, @"XCSynthesizedEventRecord");
69-
if (missing != nil) return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusUnavailable, missing);
71+
if (missing != nil) return RunnerTextEntryUnavailable(missing);
7072
missing = RunnerRequireTextClass(pathClass, @"XCPointerEventPath");
71-
if (missing != nil) return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusUnavailable, missing);
73+
if (missing != nil) return RunnerTextEntryUnavailable(missing);
7274
missing = RunnerRequireTextSelector(recordClass, initRecord, @"initWithName:");
73-
if (missing != nil) return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusUnavailable, missing);
75+
if (missing != nil) return RunnerTextEntryUnavailable(missing);
7476
missing = RunnerRequireTextSelector(recordClass, addPath, @"addPointerEventPath:");
75-
if (missing != nil) return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusUnavailable, missing);
77+
if (missing != nil) return RunnerTextEntryUnavailable(missing);
7678
missing = RunnerRequireTextSelector(recordClass, setTargetProcessID, @"setTargetProcessID:");
77-
if (missing != nil) return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusUnavailable, missing);
79+
if (missing != nil) return RunnerTextEntryUnavailable(missing);
7880
missing = RunnerRequireTextSelector(recordClass, synthesize, @"synthesizeWithError:");
79-
if (missing != nil) return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusUnavailable, missing);
81+
if (missing != nil) return RunnerTextEntryUnavailable(missing);
8082
missing = RunnerRequireTextSelector(pathClass, initPath, @"initForTextInput");
81-
if (missing != nil) return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusUnavailable, missing);
83+
if (missing != nil) return RunnerTextEntryUnavailable(missing);
8284
missing = RunnerRequireTextSelector(pathClass, typeText, @"typeText:atOffset:typingSpeed:shouldRedact:");
83-
if (missing != nil) return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusUnavailable, missing);
85+
if (missing != nil) return RunnerTextEntryUnavailable(missing);
8486
if (replace) {
8587
missing = RunnerRequireTextSelector(pathClass, typeKey, @"typeKey:modifiers:atOffset:");
86-
if (missing != nil) return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusUnavailable, missing);
88+
if (missing != nil) return RunnerTextEntryUnavailable(missing);
8789
}
8890
if (![application respondsToSelector:processID]) {
89-
return RunnerTextEntryResult(
90-
RunnerSynthesizedTextEntryStatusUnavailable,
91+
return RunnerTextEntryUnavailable(
9192
@"private XCTest text synthesis unavailable: XCUIApplication missing processID"
9293
);
9394
}
9495

9596
NSInteger targetProcessID =
9697
((RunnerTextMsgSendInteger)objc_msgSend)(application, processID);
9798
if (targetProcessID <= 0) {
98-
return RunnerTextEntryResult(
99-
RunnerSynthesizedTextEntryStatusUnavailable,
99+
return RunnerTextEntryUnavailable(
100100
@"private XCTest text synthesis unavailable: could not resolve target process ID"
101101
);
102102
}
@@ -107,8 +107,7 @@ + (RunnerSynthesizedTextEntryResult *)replaceTextWithApplication:(id)application
107107
);
108108
id selectionPath = ((RunnerTextMsgSendInitPath)objc_msgSend)([pathClass alloc], initPath);
109109
if (selectionRecord == nil || selectionPath == nil) {
110-
return RunnerTextEntryResult(
111-
RunnerSynthesizedTextEntryStatusFailed,
110+
return RunnerTextEntryFailed(
112111
@"private XCTest text synthesis failed: could not create the selection event"
113112
);
114113
}
@@ -130,8 +129,7 @@ + (RunnerSynthesizedTextEntryResult *)replaceTextWithApplication:(id)application
130129
);
131130
if (!selected) {
132131
NSString *detail = selectionError.localizedDescription ?: @"synthesizeWithError returned false";
133-
return RunnerTextEntryResult(
134-
RunnerSynthesizedTextEntryStatusFailed,
132+
return RunnerTextEntryFailed(
135133
[NSString stringWithFormat:@"private XCTest text selection failed: %@", detail]
136134
);
137135
}
@@ -142,8 +140,7 @@ + (RunnerSynthesizedTextEntryResult *)replaceTextWithApplication:(id)application
142140
);
143141
id path = ((RunnerTextMsgSendInitPath)objc_msgSend)([pathClass alloc], initPath);
144142
if (record == nil || path == nil) {
145-
return RunnerTextEntryResult(
146-
RunnerSynthesizedTextEntryStatusFailed,
143+
return RunnerTextEntryFailed(
147144
@"private XCTest text synthesis failed: could not create the text event"
148145
);
149146
}
@@ -155,19 +152,15 @@ + (RunnerSynthesizedTextEntryResult *)replaceTextWithApplication:(id)application
155152
BOOL ok = ((RunnerTextMsgSendSynthesize)objc_msgSend)(record, synthesize, &error);
156153
if (!ok) {
157154
NSString *detail = error.localizedDescription ?: @"synthesizeWithError returned false";
158-
return RunnerTextEntryResult(
159-
RunnerSynthesizedTextEntryStatusFailed,
155+
return RunnerTextEntryFailed(
160156
[NSString stringWithFormat:@"private XCTest text synthesis failed: %@", detail]
161157
);
162158
}
163159
return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusSucceeded, nil);
164160
} @catch (NSException *exception) {
165161
NSString *name = exception.name ?: @"NSException";
166162
NSString *reason = exception.reason ?: @"private XCTest text synthesis failed";
167-
return RunnerTextEntryResult(
168-
RunnerSynthesizedTextEntryStatusFailed,
169-
[NSString stringWithFormat:@"%@: %@", name, reason]
170-
);
163+
return RunnerTextEntryFailed([NSString stringWithFormat:@"%@: %@", name, reason]);
171164
}
172165
}
173166

@@ -181,6 +174,14 @@ + (RunnerSynthesizedTextEntryResult *)replaceTextWithApplication:(id)application
181174
return result;
182175
}
183176

177+
static RunnerSynthesizedTextEntryResult *RunnerTextEntryUnavailable(NSString *message) {
178+
return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusUnavailable, message);
179+
}
180+
181+
static RunnerSynthesizedTextEntryResult *RunnerTextEntryFailed(NSString *message) {
182+
return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusFailed, message);
183+
}
184+
184185
static NSString * _Nullable RunnerRequireTextClass(Class cls, NSString *name) {
185186
if (cls == Nil) {
186187
return [NSString stringWithFormat:@"private XCTest text synthesis unavailable: missing %@", name];
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import XCTest
2+
3+
// AX-independent text synthesis: private-XCTest boundary, pacing plan, and route policy.
4+
// The verified XCUIElement path remains in TextEntry/TextTyping.
5+
extension RunnerTests {
6+
enum SynthesizedReplacementRouteOutcome {
7+
case notApplicable
8+
case completed(TextEntryResult)
9+
case fallback(TextEntryTarget)
10+
}
11+
12+
struct SynthesizedReplacementRequest {
13+
let app: XCUIApplication
14+
let target: TextEntryTarget
15+
let text: String
16+
let delaySeconds: Double
17+
let synthesizer: any TextEntrySynthesizing
18+
let commandId: String?
19+
let startedAt: Date
20+
}
21+
22+
enum SynthesizedTextEntryAction: Equatable {
23+
case continueTyping
24+
case fallback
25+
case raise(String?)
26+
}
27+
28+
protocol TextEntrySynthesizing {
29+
func enterText(
30+
app: XCUIApplication,
31+
text: String,
32+
replacingExistingText: Bool
33+
) -> SynthesizedTextEntryAction
34+
}
35+
36+
struct PrivateXCTestTextEntrySynthesizer: TextEntrySynthesizing {
37+
func enterText(
38+
app: XCUIApplication,
39+
text: String,
40+
replacingExistingText: Bool
41+
) -> SynthesizedTextEntryAction {
42+
#if os(iOS)
43+
let result = replacingExistingText
44+
? RunnerSynthesizedTextEntry.replaceText(withApplication: app, text: text)
45+
: RunnerSynthesizedTextEntry.synthesizeText(withApplication: app, text: text)
46+
return Self.action(status: result.status, message: result.message)
47+
#else
48+
return .fallback
49+
#endif
50+
}
51+
52+
#if os(iOS)
53+
static func action(
54+
status: RunnerSynthesizedTextEntryStatus,
55+
message: String?
56+
) -> SynthesizedTextEntryAction {
57+
switch status {
58+
case .succeeded:
59+
return .continueTyping
60+
case .unavailable:
61+
return .fallback
62+
case .failed:
63+
return .raise(message)
64+
@unknown default:
65+
return .raise(message)
66+
}
67+
}
68+
#endif
69+
}
70+
71+
struct SynthesizedReplacementStep: Equatable {
72+
let text: String
73+
let replacesExistingText: Bool
74+
}
75+
76+
static func synthesizedReplacementSteps(
77+
text: String,
78+
delaySeconds: Double
79+
) -> [SynthesizedReplacementStep] {
80+
let characters = Array(text)
81+
guard delaySeconds > 0, characters.count > 1 else {
82+
return [SynthesizedReplacementStep(text: text, replacesExistingText: true)]
83+
}
84+
return characters.enumerated().map { index, character in
85+
SynthesizedReplacementStep(
86+
text: String(character),
87+
replacesExistingText: index == 0
88+
)
89+
}
90+
}
91+
92+
func runSynthesizedReplacementRoute(
93+
_ request: SynthesizedReplacementRequest
94+
) -> SynthesizedReplacementRouteOutcome {
95+
#if os(iOS)
96+
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=synthesized-first-responder-replacement")
97+
let steps = Self.synthesizedReplacementSteps(
98+
text: request.text,
99+
delaySeconds: request.delaySeconds
100+
)
101+
for (index, step) in steps.enumerated() {
102+
switch request.synthesizer.enterText(
103+
app: request.app,
104+
text: step.text,
105+
replacingExistingText: step.replacesExistingText
106+
) {
107+
case .fallback:
108+
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=verified-fallback reason=synthesis-unavailable")
109+
guard let point = request.target.refreshPoint else { return .notApplicable }
110+
return .fallback(
111+
focusTextInputForTextEntry(app: request.app, x: point.x, y: point.y)
112+
)
113+
case .raise(let message):
114+
NSException(
115+
name: NSExceptionName.internalInconsistencyException,
116+
reason: message ?? "private XCTest text synthesis failed"
117+
).raise()
118+
case .continueTyping:
119+
break
120+
}
121+
if index + 1 < steps.count {
122+
sleepFor(request.delaySeconds)
123+
}
124+
}
125+
logTextEntryPhase(
126+
commandId: request.commandId,
127+
phase: "total",
128+
startedAt: request.startedAt,
129+
chars: request.text.count,
130+
mode: .replacement
131+
)
132+
return .completed(
133+
TextEntryResult(
134+
verified: nil,
135+
repaired: false,
136+
expectedText: request.text,
137+
observedText: nil
138+
)
139+
)
140+
#else
141+
return .notApplicable
142+
#endif
143+
}
144+
145+
static func shouldUseSynthesizedFirstResponderReplacement(
146+
hasResolvedElement: Bool,
147+
hasRefreshPoint: Bool,
148+
xCTestChannelPenalized: Bool
149+
) -> Bool {
150+
!hasResolvedElement && hasRefreshPoint && xCTestChannelPenalized
151+
}
152+
153+
static func shouldUseResolvedCoordinateTextEntryRoute(
154+
repairMode: TextTypingRepairMode,
155+
hasX: Bool,
156+
hasY: Bool,
157+
xCTestChannelPenalized: Bool
158+
) -> Bool {
159+
repairMode == .replacement && hasX && hasY && xCTestChannelPenalized
160+
}
161+
162+
static func shouldFallbackFromSynthesizedTextEntryFocus(
163+
_ outcome: RunnerInteractionOutcome
164+
) -> Bool {
165+
if case .unsupported = outcome { return true }
166+
return false
167+
}
168+
}

0 commit comments

Comments
 (0)