agent-device 0.16.14 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.14.apk → agent-device-android-multitouch-helper-0.17.1.apk} +0 -0
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.17.1.apk.sha256 +1 -0
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.14.manifest.json → agent-device-android-multitouch-helper-0.17.1.manifest.json} +4 -4
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.14.apk → agent-device-android-snapshot-helper-0.17.1.apk} +0 -0
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.17.1.apk.sha256 +1 -0
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.14.manifest.json → agent-device-android-snapshot-helper-0.17.1.manifest.json} +6 -6
- package/dist/src/1352.js +1 -1
- package/dist/src/221.js +4 -4
- package/dist/src/2415.js +29 -29
- package/dist/src/2805.js +1 -1
- package/dist/src/6232.js +1 -1
- package/dist/src/7599.js +4 -3
- package/dist/src/8020.js +1 -0
- package/dist/src/8699.js +1 -1
- package/dist/src/9238.js +3 -3
- package/dist/src/940.js +1 -1
- package/dist/src/9533.js +1 -1
- package/dist/src/9542.js +3 -3
- package/dist/src/android-snapshot-helper.d.ts +1 -0
- package/dist/src/apple.js +1 -1
- package/dist/src/apps.js +1 -1
- package/dist/src/args.js +15 -10
- package/dist/src/cli.js +9 -9
- package/dist/src/command-metadata.js +1 -1
- package/dist/src/contracts.d.ts +1 -0
- package/dist/src/find.js +1 -1
- package/dist/src/finders.d.ts +1 -0
- package/dist/src/generic.js +12 -10
- package/dist/src/index.d.ts +20 -1
- package/dist/src/interaction.js +1 -1
- package/dist/src/record-trace-recording.js +26 -0
- package/dist/src/record-trace.js +1 -26
- package/dist/src/selectors.d.ts +1 -0
- package/dist/src/session.js +11 -11
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +4 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +71 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +41 -7
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +160 -13
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +11 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +12 -4
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +26 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +8 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +7 -1
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +571 -56
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +21 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +11 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +13 -2
- package/ios-runner/README.md +13 -0
- package/package.json +2 -2
- package/server.json +2 -2
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.14.apk.sha256 +0 -1
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.14.apk.sha256 +0 -1
|
@@ -53,6 +53,10 @@ static id RunnerSwipePointerPath(
|
|
|
53
53
|
CGPoint end,
|
|
54
54
|
double durationMs
|
|
55
55
|
);
|
|
56
|
+
static id RunnerTapPointerPath(
|
|
57
|
+
const RunnerXCTestEventBridge *bridge,
|
|
58
|
+
CGPoint point
|
|
59
|
+
);
|
|
56
60
|
static CGPoint RunnerPointerPointAt(
|
|
57
61
|
double x,
|
|
58
62
|
double y,
|
|
@@ -115,6 +119,18 @@ static double RunnerSmoothStep(double t);
|
|
|
115
119
|
}
|
|
116
120
|
}
|
|
117
121
|
|
|
122
|
+
+ (NSString * _Nullable)synthesizeTapWithApplication:(id)application
|
|
123
|
+
x:(double)x
|
|
124
|
+
y:(double)y {
|
|
125
|
+
@try {
|
|
126
|
+
return [self trySynthesizeTapWithApplication:application x:x y:y];
|
|
127
|
+
} @catch (NSException *exception) {
|
|
128
|
+
NSString *name = exception.name ?: @"NSException";
|
|
129
|
+
NSString *reason = exception.reason ?: @"private XCTest event synthesis failed";
|
|
130
|
+
return [NSString stringWithFormat:@"%@: %@", name, reason];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
118
134
|
+ (NSString * _Nullable)trySynthesizeTransformWithApplication:(id)application
|
|
119
135
|
x:(double)x
|
|
120
136
|
y:(double)y
|
|
@@ -224,6 +240,48 @@ static double RunnerSmoothStep(double t);
|
|
|
224
240
|
return nil;
|
|
225
241
|
}
|
|
226
242
|
|
|
243
|
+
+ (NSString * _Nullable)trySynthesizeTapWithApplication:(id)application
|
|
244
|
+
x:(double)x
|
|
245
|
+
y:(double)y {
|
|
246
|
+
RunnerXCTestEventBridge bridge;
|
|
247
|
+
NSString *missing = RunnerResolveXCTestEventBridge(application, &bridge);
|
|
248
|
+
if (missing != nil) {
|
|
249
|
+
return missing;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
NSInteger interfaceOrientation =
|
|
253
|
+
((RunnerMsgSendInteger)objc_msgSend)(application, bridge.interfaceOrientationSelector);
|
|
254
|
+
NSInteger targetProcessID = ((RunnerMsgSendInteger)objc_msgSend)(application, bridge.processIDSelector);
|
|
255
|
+
if (targetProcessID <= 0) {
|
|
256
|
+
return @"private XCTest event synthesis unavailable: could not resolve target process ID";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
id record = ((RunnerMsgSendInitRecord)objc_msgSend)(
|
|
260
|
+
[bridge.recordClass alloc],
|
|
261
|
+
bridge.initRecordSelector,
|
|
262
|
+
@"agent-device-tap",
|
|
263
|
+
interfaceOrientation
|
|
264
|
+
);
|
|
265
|
+
if (record == nil) {
|
|
266
|
+
return @"private XCTest event synthesis failed: could not create event record";
|
|
267
|
+
}
|
|
268
|
+
((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge.setTargetProcessIDSelector, targetProcessID);
|
|
269
|
+
|
|
270
|
+
id path = RunnerTapPointerPath(&bridge, CGPointMake(x, y));
|
|
271
|
+
if (path == nil) {
|
|
272
|
+
return @"private XCTest event synthesis failed: could not create pointer path";
|
|
273
|
+
}
|
|
274
|
+
((RunnerMsgSendAddPath)objc_msgSend)(record, bridge.addPathSelector, path);
|
|
275
|
+
|
|
276
|
+
NSError *error = nil;
|
|
277
|
+
BOOL ok = ((RunnerMsgSendSynthesize)objc_msgSend)(record, bridge.synthesizeSelector, &error);
|
|
278
|
+
if (!ok) {
|
|
279
|
+
NSString *detail = error.localizedDescription ?: @"synthesizeWithError returned false";
|
|
280
|
+
return [NSString stringWithFormat:@"private XCTest event synthesis failed: %@", detail];
|
|
281
|
+
}
|
|
282
|
+
return nil;
|
|
283
|
+
}
|
|
284
|
+
|
|
227
285
|
static NSString * _Nullable RunnerResolveXCTestEventBridge(
|
|
228
286
|
id application,
|
|
229
287
|
RunnerXCTestEventBridge *bridge
|
|
@@ -368,6 +426,19 @@ static id RunnerSwipePointerPath(
|
|
|
368
426
|
return path;
|
|
369
427
|
}
|
|
370
428
|
|
|
429
|
+
static id RunnerTapPointerPath(
|
|
430
|
+
const RunnerXCTestEventBridge *bridge,
|
|
431
|
+
CGPoint point
|
|
432
|
+
) {
|
|
433
|
+
id path =
|
|
434
|
+
((RunnerMsgSendInitPath)objc_msgSend)([bridge->pathClass alloc], bridge->initPathSelector, point, 0.0);
|
|
435
|
+
if (path == nil) {
|
|
436
|
+
return nil;
|
|
437
|
+
}
|
|
438
|
+
((RunnerMsgSendPathOffset)objc_msgSend)(path, bridge->liftSelector, 0.05);
|
|
439
|
+
return path;
|
|
440
|
+
}
|
|
441
|
+
|
|
371
442
|
static CGPoint RunnerPointerPointAt(
|
|
372
443
|
double x,
|
|
373
444
|
double y,
|
|
@@ -8,20 +8,18 @@ extension RunnerTests {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? {
|
|
11
|
+
#if !os(macOS)
|
|
12
|
+
if let systemModal = firstBlockingSystemModal(in: springboard) {
|
|
13
|
+
return runnerAlert(root: systemModal, ownerApp: springboard)
|
|
14
|
+
}
|
|
15
|
+
#endif
|
|
11
16
|
if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) {
|
|
12
17
|
return runnerAlert(root: alert, ownerApp: activeApp)
|
|
13
18
|
}
|
|
14
19
|
if let popup = firstDismissPopupWindow(in: activeApp) {
|
|
15
20
|
return runnerAlert(root: popup, ownerApp: activeApp)
|
|
16
21
|
}
|
|
17
|
-
#if os(macOS)
|
|
18
|
-
return nil
|
|
19
|
-
#else
|
|
20
|
-
if let systemModal = firstBlockingSystemModal(in: springboard) {
|
|
21
|
-
return runnerAlert(root: systemModal, ownerApp: springboard)
|
|
22
|
-
}
|
|
23
22
|
return nil
|
|
24
|
-
#endif
|
|
25
23
|
}
|
|
26
24
|
|
|
27
25
|
func handleAlert(_ alert: RunnerAlert, action: String) -> Response {
|
|
@@ -33,6 +31,27 @@ extension RunnerTests {
|
|
|
33
31
|
if let response = unsupportedResponse(for: outcome) {
|
|
34
32
|
return response
|
|
35
33
|
}
|
|
34
|
+
sleepFor(0.2)
|
|
35
|
+
if alertStillVisible(alert, actionButtonLabel: button.label) {
|
|
36
|
+
let frame = button.frame
|
|
37
|
+
if !frame.isNull && !frame.isEmpty {
|
|
38
|
+
let coordinateOutcome = tapAt(app: alert.ownerApp, x: frame.midX, y: frame.midY)
|
|
39
|
+
if let response = unsupportedResponse(for: coordinateOutcome) {
|
|
40
|
+
return response
|
|
41
|
+
}
|
|
42
|
+
sleepFor(0.2)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if alertStillVisible(alert, actionButtonLabel: button.label) {
|
|
46
|
+
return Response(
|
|
47
|
+
ok: false,
|
|
48
|
+
error: ErrorPayload(
|
|
49
|
+
code: "INTERACTION_FAILED",
|
|
50
|
+
message: "alert \(action) did not dismiss the visible alert",
|
|
51
|
+
hint: "The alert button was found but the system still reports the alert after tapping it."
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
}
|
|
36
55
|
return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
|
|
37
56
|
}
|
|
38
57
|
|
|
@@ -53,6 +72,21 @@ extension RunnerTests {
|
|
|
53
72
|
return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons)
|
|
54
73
|
}
|
|
55
74
|
|
|
75
|
+
private func alertStillVisible(_ alert: RunnerAlert, actionButtonLabel: String) -> Bool {
|
|
76
|
+
guard let current = resolveAlert(app: alert.ownerApp) else {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons)
|
|
80
|
+
let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons)
|
|
81
|
+
if previousTitle == currentTitle {
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
let normalizedActionLabel = actionButtonLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
85
|
+
return current.buttons.contains { button in
|
|
86
|
+
button.label.trimmingCharacters(in: .whitespacesAndNewlines) == normalizedActionLabel
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
|
|
57
91
|
elements.first { isVisibleElement($0) }
|
|
58
92
|
}
|
package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift
CHANGED
|
@@ -17,6 +17,10 @@ extension RunnerTests {
|
|
|
17
17
|
min(max((durationMs / 5.0) / 1000.0, 0.016), 0.120)
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
private func coordinateDragHoldDuration() -> TimeInterval {
|
|
21
|
+
0.050
|
|
22
|
+
}
|
|
23
|
+
|
|
20
24
|
func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
|
|
21
25
|
switch outcome {
|
|
22
26
|
case .performed:
|
|
@@ -147,10 +151,64 @@ extension RunnerTests {
|
|
|
147
151
|
return Response(ok: true, data: data)
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
func testGestureResponseIncludesSynthesizedTapFallbackDiagnostics() {
|
|
155
|
+
let response = gestureResponse(
|
|
156
|
+
message: "tapped",
|
|
157
|
+
timing: (gestureStartUptimeMs: 1, gestureEndUptimeMs: 2),
|
|
158
|
+
fallback: GestureFallback(
|
|
159
|
+
strategy: "xctest-coordinate-tap",
|
|
160
|
+
message: "Runner synthesized coordinate tap is unavailable",
|
|
161
|
+
hint: "Using XCTest coordinate tap fallback."
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
XCTAssertEqual(response.ok, true)
|
|
166
|
+
XCTAssertEqual(response.data?.gestureFallback, "xctest-coordinate-tap")
|
|
167
|
+
XCTAssertEqual(
|
|
168
|
+
response.data?.gestureFallbackMessage,
|
|
169
|
+
"Runner synthesized coordinate tap is unavailable"
|
|
170
|
+
)
|
|
171
|
+
XCTAssertEqual(response.data?.gestureFallbackHint, "Using XCTest coordinate tap fallback.")
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func testXCTestRecordedFailureResponseFailsMutatingSuccesses() throws {
|
|
175
|
+
let command = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
|
|
176
|
+
let response = Response(ok: true, data: DataPayload(message: "tapped"))
|
|
177
|
+
|
|
178
|
+
let failureResponse = xctestRecordedFailureResponse(command: command, response: response)
|
|
179
|
+
|
|
180
|
+
XCTAssertEqual(failureResponse?.ok, false)
|
|
181
|
+
XCTAssertEqual(failureResponse?.error?.code, "XCTEST_RECORDED_FAILURE")
|
|
182
|
+
XCTAssertEqual(
|
|
183
|
+
failureResponse?.error?.message,
|
|
184
|
+
"XCTest recorded a failure while executing tap; the action may not have been performed."
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func testXCTestRecordedFailureResponseDoesNotWrapReadOnlyOrRunnerFatalResponses() throws {
|
|
189
|
+
let snapshotCommand = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-1"}"#)
|
|
190
|
+
let tapCommand = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
|
|
191
|
+
let runnerFatalResponse = Response(
|
|
192
|
+
ok: true,
|
|
193
|
+
data: DataPayload(runnerFatal: true, runnerFatalReason: "ax_snapshot_unavailable")
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
XCTAssertNil(
|
|
197
|
+
xctestRecordedFailureResponse(
|
|
198
|
+
command: snapshotCommand,
|
|
199
|
+
response: Response(ok: true, data: DataPayload(nodes: [], truncated: false))
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
XCTAssertNil(xctestRecordedFailureResponse(command: tapCommand, response: runnerFatalResponse))
|
|
203
|
+
}
|
|
204
|
+
|
|
150
205
|
func execute(command: Command) throws -> Response {
|
|
151
206
|
if command.command == .status {
|
|
152
207
|
return executeStatus(command: command)
|
|
153
208
|
}
|
|
209
|
+
if command.command == .uptime {
|
|
210
|
+
return executeUptime()
|
|
211
|
+
}
|
|
154
212
|
commandJournal.accept(command: command)
|
|
155
213
|
return try executeAccepted(command: command)
|
|
156
214
|
}
|
|
@@ -185,6 +243,13 @@ extension RunnerTests {
|
|
|
185
243
|
return Response(ok: true, data: commandJournal.status(commandId: statusCommandId))
|
|
186
244
|
}
|
|
187
245
|
|
|
246
|
+
func executeUptime() -> Response {
|
|
247
|
+
Response(
|
|
248
|
+
ok: true,
|
|
249
|
+
data: DataPayload(currentUptimeMs: currentUptimeMs())
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
188
253
|
private func executeDispatched(command: Command) throws -> Response {
|
|
189
254
|
if Thread.isMainThread {
|
|
190
255
|
return try executeOnMainSafely(command: command)
|
|
@@ -229,6 +294,7 @@ extension RunnerTests {
|
|
|
229
294
|
while true {
|
|
230
295
|
var response: Response?
|
|
231
296
|
var swiftError: Error?
|
|
297
|
+
let failureCountBefore = currentXCTestFailureCount()
|
|
232
298
|
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
233
299
|
do {
|
|
234
300
|
response = try self.executeOnMain(command: command)
|
|
@@ -238,8 +304,7 @@ extension RunnerTests {
|
|
|
238
304
|
})
|
|
239
305
|
|
|
240
306
|
if let exceptionMessage {
|
|
241
|
-
|
|
242
|
-
currentBundleId = nil
|
|
307
|
+
invalidateCachedTarget(reason: "objc_exception")
|
|
243
308
|
if !hasRetried, shouldRetryException(command, message: exceptionMessage) {
|
|
244
309
|
NSLog(
|
|
245
310
|
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=objc_exception",
|
|
@@ -265,14 +330,19 @@ extension RunnerTests {
|
|
|
265
330
|
userInfo: [NSLocalizedDescriptionKey: "command returned no response"]
|
|
266
331
|
)
|
|
267
332
|
}
|
|
333
|
+
if didRecordXCTestFailure(since: failureCountBefore),
|
|
334
|
+
let failureResponse = xctestRecordedFailureResponse(command: command, response: response)
|
|
335
|
+
{
|
|
336
|
+
invalidateCachedTarget(reason: "xctest_recorded_failure")
|
|
337
|
+
return failureResponse
|
|
338
|
+
}
|
|
268
339
|
if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
|
|
269
340
|
NSLog(
|
|
270
341
|
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
|
|
271
342
|
command.command.rawValue
|
|
272
343
|
)
|
|
273
344
|
hasRetried = true
|
|
274
|
-
|
|
275
|
-
currentBundleId = nil
|
|
345
|
+
invalidateCachedTarget(reason: "response_unavailable")
|
|
276
346
|
sleepFor(retryCooldown)
|
|
277
347
|
continue
|
|
278
348
|
}
|
|
@@ -282,7 +352,9 @@ extension RunnerTests {
|
|
|
282
352
|
|
|
283
353
|
private func executeOnMain(command: Command) throws -> Response {
|
|
284
354
|
var activeApp = currentApp ?? app
|
|
285
|
-
if
|
|
355
|
+
if shouldSkipAppActivationPreflight(command) {
|
|
356
|
+
activeApp = resolveAppWithoutActivation(command: command)
|
|
357
|
+
} else if !isRunnerLifecycleCommand(command.command) {
|
|
286
358
|
let normalizedBundleId = command.appBundleId?
|
|
287
359
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
288
360
|
let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
|
|
@@ -408,10 +480,7 @@ extension RunnerTests {
|
|
|
408
480
|
return Response(ok: false, error: ErrorPayload(message: "failed to stop recording: \(error.localizedDescription)"))
|
|
409
481
|
}
|
|
410
482
|
case .uptime:
|
|
411
|
-
return
|
|
412
|
-
ok: true,
|
|
413
|
-
data: DataPayload(currentUptimeMs: currentUptimeMs())
|
|
414
|
-
)
|
|
483
|
+
return executeUptime()
|
|
415
484
|
case .tap:
|
|
416
485
|
if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
|
|
417
486
|
let match = findElement(
|
|
@@ -425,6 +494,7 @@ extension RunnerTests {
|
|
|
425
494
|
}
|
|
426
495
|
if let element = match.element {
|
|
427
496
|
let frame = element.frame
|
|
497
|
+
let isTextEntry = isTextEntryElement(element)
|
|
428
498
|
let touchFrame = frame.isEmpty
|
|
429
499
|
? nil
|
|
430
500
|
: resolvedTouchVisualizationFrame(app: activeApp, x: frame.midX, y: frame.midY)
|
|
@@ -440,7 +510,9 @@ extension RunnerTests {
|
|
|
440
510
|
if let response = unsupportedResponse(for: outcome) {
|
|
441
511
|
return response
|
|
442
512
|
}
|
|
443
|
-
|
|
513
|
+
if isTextEntry {
|
|
514
|
+
waitForTextEntryReadinessAfterTap(app: activeApp, element: element)
|
|
515
|
+
}
|
|
444
516
|
return gestureResponse(
|
|
445
517
|
message: match.usedNonHittableFallback ? "tapped via non-hittable coordinate fallback" : "tapped",
|
|
446
518
|
timing: timing,
|
|
@@ -462,12 +534,27 @@ extension RunnerTests {
|
|
|
462
534
|
return Response(ok: false, error: ErrorPayload(message: "element not found"))
|
|
463
535
|
}
|
|
464
536
|
if let x = command.x, let y = command.y {
|
|
537
|
+
var fallback: GestureFallback?
|
|
538
|
+
if command.synthesized == true {
|
|
539
|
+
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
|
|
540
|
+
synthesizedTapAt(app: activeApp, x: x, y: y)
|
|
541
|
+
}
|
|
542
|
+
if case .performed = outcome {
|
|
543
|
+
return gestureResponse(message: "tapped", timing: timing)
|
|
544
|
+
}
|
|
545
|
+
fallback = gestureFallback(strategy: "xctest-coordinate-tap", from: outcome)
|
|
546
|
+
}
|
|
465
547
|
let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
|
|
466
548
|
let (timing, outcome) = performGesture(activeApp) { tapAt(app: activeApp, x: x, y: y) }
|
|
467
549
|
if let response = unsupportedResponse(for: outcome) {
|
|
468
550
|
return response
|
|
469
551
|
}
|
|
470
|
-
return gestureResponse(
|
|
552
|
+
return gestureResponse(
|
|
553
|
+
message: "tapped",
|
|
554
|
+
timing: timing,
|
|
555
|
+
frame: .touch(touchFrame),
|
|
556
|
+
fallback: fallback
|
|
557
|
+
)
|
|
471
558
|
}
|
|
472
559
|
return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y"))
|
|
473
560
|
case .mouseClick:
|
|
@@ -574,7 +661,7 @@ extension RunnerTests {
|
|
|
574
661
|
}
|
|
575
662
|
let holdDuration = command.synthesized == true
|
|
576
663
|
? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
|
|
577
|
-
:
|
|
664
|
+
: coordinateDragHoldDuration()
|
|
578
665
|
let (timing, outcome) = performGesture(activeApp) {
|
|
579
666
|
dragAt(
|
|
580
667
|
app: activeApp,
|
|
@@ -632,7 +719,7 @@ extension RunnerTests {
|
|
|
632
719
|
}
|
|
633
720
|
let holdDuration = command.synthesized == true
|
|
634
721
|
? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
|
|
635
|
-
:
|
|
722
|
+
: coordinateDragHoldDuration()
|
|
636
723
|
let (timing, outcome) = performGesture(activeApp) {
|
|
637
724
|
performDragSeries(
|
|
638
725
|
count: count,
|
|
@@ -736,6 +823,7 @@ extension RunnerTests {
|
|
|
736
823
|
needsPostSnapshotInteractionDelay = true
|
|
737
824
|
return Response(ok: true, data: payload)
|
|
738
825
|
} catch let failure as SnapshotCaptureFailure {
|
|
826
|
+
invalidateCachedTarget(reason: "ax_snapshot_failure")
|
|
739
827
|
// Other thrown errors fall through to executeOnMainSafely's generic error response.
|
|
740
828
|
return Response(
|
|
741
829
|
ok: false,
|
|
@@ -935,6 +1023,65 @@ extension RunnerTests {
|
|
|
935
1023
|
}
|
|
936
1024
|
}
|
|
937
1025
|
|
|
1026
|
+
private func currentXCTestFailureCount() -> Int {
|
|
1027
|
+
return testRun?.failureCount ?? 0
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
private func didRecordXCTestFailure(since failureCountBefore: Int) -> Bool {
|
|
1031
|
+
return currentXCTestFailureCount() > failureCountBefore
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
private func xctestRecordedFailureResponse(command: Command, response: Response) -> Response? {
|
|
1035
|
+
guard response.ok else { return nil }
|
|
1036
|
+
if response.data?.runnerFatal == true {
|
|
1037
|
+
return nil
|
|
1038
|
+
}
|
|
1039
|
+
guard !isReadOnlyCommand(command), !isRunnerLifecycleCommand(command.command) else {
|
|
1040
|
+
return nil
|
|
1041
|
+
}
|
|
1042
|
+
return Response(
|
|
1043
|
+
ok: false,
|
|
1044
|
+
error: ErrorPayload(
|
|
1045
|
+
code: "XCTEST_RECORDED_FAILURE",
|
|
1046
|
+
message: "XCTest recorded a failure while executing \(command.command.rawValue); the action may not have been performed.",
|
|
1047
|
+
hint: "The iOS runner session will be restarted. Retry after a fresh snapshot, or use screenshot plus coordinate commands when the accessibility tree is unavailable."
|
|
1048
|
+
)
|
|
1049
|
+
)
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
private func runnerCommandFixture(_ json: String) throws -> Command {
|
|
1053
|
+
try JSONDecoder().decode(Command.self, from: Data(json.utf8))
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private func shouldSkipAppActivationPreflight(_ command: Command) -> Bool {
|
|
1057
|
+
#if os(iOS)
|
|
1058
|
+
// Coordinate-only synthesized taps can run after an AX-fatal screen because they do not need
|
|
1059
|
+
// app activation, window lookup, keyboard lookup, or element resolution. Selector/text taps
|
|
1060
|
+
// intentionally stay on the normal AX path because they need an element query.
|
|
1061
|
+
return command.command == .tap
|
|
1062
|
+
&& command.synthesized == true
|
|
1063
|
+
&& command.x != nil
|
|
1064
|
+
&& command.y != nil
|
|
1065
|
+
&& command.text == nil
|
|
1066
|
+
&& command.selectorKey == nil
|
|
1067
|
+
#else
|
|
1068
|
+
return false
|
|
1069
|
+
#endif
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
private func resolveAppWithoutActivation(command: Command) -> XCUIApplication {
|
|
1073
|
+
guard let bundleId = command.appBundleId?
|
|
1074
|
+
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
1075
|
+
!bundleId.isEmpty
|
|
1076
|
+
else {
|
|
1077
|
+
return currentApp ?? app
|
|
1078
|
+
}
|
|
1079
|
+
if currentBundleId == bundleId, let currentApp {
|
|
1080
|
+
return currentApp
|
|
1081
|
+
}
|
|
1082
|
+
return XCUIApplication(bundleIdentifier: bundleId)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
938
1085
|
private func executeTypeCommand(activeApp: XCUIApplication, command: Command) -> Response {
|
|
939
1086
|
guard let text = command.text else {
|
|
940
1087
|
return Response(ok: false, error: ErrorPayload(message: "type requires text"))
|
package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift
CHANGED
|
@@ -154,6 +154,17 @@ final class RunnerCommandJournal {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
extension RunnerTests {
|
|
157
|
+
func testUptimeBypassesCommandJournal() throws {
|
|
158
|
+
let command = runnerJournalCommand("uptime", id: "uptime-probe")
|
|
159
|
+
|
|
160
|
+
let response = try execute(command: command)
|
|
161
|
+
let status = commandJournal.status(commandId: "uptime-probe")
|
|
162
|
+
|
|
163
|
+
XCTAssertEqual(response.ok, true)
|
|
164
|
+
XCTAssertNotNil(response.data?.currentUptimeMs)
|
|
165
|
+
XCTAssertEqual(status.lifecycleState, RunnerCommandLifecycleState.notAccepted.rawValue)
|
|
166
|
+
}
|
|
167
|
+
|
|
157
168
|
func testCommandJournalRetentionPolicy() throws {
|
|
158
169
|
let journal = RunnerCommandJournal()
|
|
159
170
|
|
|
@@ -10,10 +10,7 @@ extension RunnerTests {
|
|
|
10
10
|
/// exception telemetry later. `RunnerObjCExceptionCatcher.catchException` takes a non-escaping
|
|
11
11
|
/// block, so `block` may capture `inout` state.
|
|
12
12
|
func safely<T>(_ tag: String, _ fallback: T, _ block: () -> T) -> T {
|
|
13
|
-
|
|
14
|
-
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
15
|
-
result = block()
|
|
16
|
-
})
|
|
13
|
+
let (result, exceptionMessage) = catchingObjCException(fallback: fallback, block)
|
|
17
14
|
if let exceptionMessage {
|
|
18
15
|
NSLog("AGENT_DEVICE_RUNNER_%@_IGNORED_EXCEPTION=%@", tag, exceptionMessage)
|
|
19
16
|
return fallback
|
|
@@ -21,6 +18,17 @@ extension RunnerTests {
|
|
|
21
18
|
return result
|
|
22
19
|
}
|
|
23
20
|
|
|
21
|
+
func catchingObjCException<T>(
|
|
22
|
+
fallback: T,
|
|
23
|
+
_ block: () -> T
|
|
24
|
+
) -> (result: T, exceptionMessage: String?) {
|
|
25
|
+
var result = fallback
|
|
26
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
27
|
+
result = block()
|
|
28
|
+
})
|
|
29
|
+
return (result, exceptionMessage)
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
/// Optional-returning convenience: returns `nil` on exception (matching the common
|
|
25
33
|
/// `var x: T?` + catch-and-return-nil shape).
|
|
26
34
|
func safely<T>(_ tag: String, _ block: () -> T?) -> T? {
|
|
@@ -670,6 +670,32 @@ extension RunnerTests {
|
|
|
670
670
|
#endif
|
|
671
671
|
}
|
|
672
672
|
|
|
673
|
+
func synthesizedTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
|
|
674
|
+
#if os(iOS)
|
|
675
|
+
if let message = RunnerSynthesizedGesture.synthesizeTap(
|
|
676
|
+
withApplication: app,
|
|
677
|
+
x: x,
|
|
678
|
+
y: y
|
|
679
|
+
) {
|
|
680
|
+
return .unsupported(
|
|
681
|
+
message: message,
|
|
682
|
+
hint: "Falling back to XCTest coordinate tap may be slower and can still need a healthy accessibility tree."
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
return .performed
|
|
686
|
+
#elseif os(tvOS)
|
|
687
|
+
return .unsupported(
|
|
688
|
+
message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
|
|
689
|
+
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
|
|
690
|
+
)
|
|
691
|
+
#else
|
|
692
|
+
return .unsupported(
|
|
693
|
+
message: "synthesized coordinate tap is not supported on macOS",
|
|
694
|
+
hint: "macOS automation has no touchscreen; use mouse-driven interactions instead."
|
|
695
|
+
)
|
|
696
|
+
#endif
|
|
697
|
+
}
|
|
698
|
+
|
|
673
699
|
func keyboardAvoidingDragPoints(
|
|
674
700
|
app: XCUIApplication,
|
|
675
701
|
x: Double,
|
|
@@ -87,6 +87,14 @@ extension RunnerTests {
|
|
|
87
87
|
currentBundleId = nil
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
func invalidateCachedTarget(reason: String) {
|
|
91
|
+
if currentApp != nil || currentBundleId != nil {
|
|
92
|
+
NSLog("AGENT_DEVICE_RUNNER_TARGET_CACHE_INVALIDATE reason=%@", reason)
|
|
93
|
+
}
|
|
94
|
+
currentApp = nil
|
|
95
|
+
currentBundleId = nil
|
|
96
|
+
}
|
|
97
|
+
|
|
90
98
|
func targetNeedsActivation(_ target: XCUIApplication) -> Bool {
|
|
91
99
|
let state = target.state
|
|
92
100
|
#if os(macOS)
|
|
@@ -193,6 +193,8 @@ struct DataPayload: Codable {
|
|
|
193
193
|
let gestureFallback: String?
|
|
194
194
|
let gestureFallbackMessage: String?
|
|
195
195
|
let gestureFallbackHint: String?
|
|
196
|
+
let runnerFatal: Bool?
|
|
197
|
+
let runnerFatalReason: String?
|
|
196
198
|
|
|
197
199
|
init(
|
|
198
200
|
message: String? = nil,
|
|
@@ -224,7 +226,9 @@ struct DataPayload: Codable {
|
|
|
224
226
|
orientation: String? = nil,
|
|
225
227
|
gestureFallback: String? = nil,
|
|
226
228
|
gestureFallbackMessage: String? = nil,
|
|
227
|
-
gestureFallbackHint: String? = nil
|
|
229
|
+
gestureFallbackHint: String? = nil,
|
|
230
|
+
runnerFatal: Bool? = nil,
|
|
231
|
+
runnerFatalReason: String? = nil
|
|
228
232
|
) {
|
|
229
233
|
self.message = message
|
|
230
234
|
self.text = text
|
|
@@ -256,6 +260,8 @@ struct DataPayload: Codable {
|
|
|
256
260
|
self.gestureFallback = gestureFallback
|
|
257
261
|
self.gestureFallbackMessage = gestureFallbackMessage
|
|
258
262
|
self.gestureFallbackHint = gestureFallbackHint
|
|
263
|
+
self.runnerFatal = runnerFatal
|
|
264
|
+
self.runnerFatalReason = runnerFatalReason
|
|
259
265
|
}
|
|
260
266
|
}
|
|
261
267
|
|