agent-device 0.20.2 → 0.20.4
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/ime-helper/dist/{agent-device-android-ime-helper-0.20.2.apk → agent-device-android-ime-helper-0.20.4.apk} +0 -0
- package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.4.apk.sha256 +1 -0
- package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.2.manifest.json → agent-device-android-ime-helper-0.20.4.manifest.json} +4 -4
- package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.4.apk +0 -0
- package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.4.apk.sha256 +1 -0
- package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.2.manifest.json → agent-device-android-snapshot-helper-0.20.4.manifest.json} +6 -6
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +57 -4
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +45 -185
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift +257 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +1 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +3 -2
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +1 -1
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +8 -2
- package/dist/src/agent-browser-tool.js +1 -1
- package/dist/src/agent-device-client.js +2 -2
- package/dist/src/android-adb.d.ts +4 -27
- package/dist/src/android-adb.js +1 -1
- package/dist/src/android.js +1 -1
- package/dist/src/app-helpers.js +1 -0
- package/dist/src/app-inventory.d.ts +4 -0
- package/dist/src/app-lifecycle.js +7 -7
- package/dist/src/app-resolution.js +1 -1
- package/dist/src/apps.js +5 -5
- package/dist/src/boot-diagnostics.js +1 -1
- package/dist/src/cli-config.js +1 -1
- package/dist/src/cli-help.js +27 -19
- package/dist/src/cli-runner.js +1 -1
- package/dist/src/cli.js +15 -14
- package/dist/src/client-metro.js +1 -1
- package/dist/src/{tv-remote.d.ts → cloud-artifacts.d.ts} +109 -126
- package/dist/src/command-metadata.js +1 -1
- package/dist/src/command-schema.js +1 -1
- package/dist/src/command-surface.js +1 -1
- package/dist/src/context.js +1 -1
- package/dist/src/contracts.d.ts +1 -1
- package/dist/src/debug-symbols.js +4 -4
- package/dist/src/device-input-state.d.ts +26 -0
- package/dist/src/device-input-state.js +1 -1
- package/dist/src/device.js +1 -9
- package/dist/src/devices2.js +1 -1
- package/dist/src/entities.js +1 -0
- package/dist/src/find.js +1 -1
- package/dist/src/finders.d.ts +1 -1
- package/dist/src/finders.js +1 -1
- package/dist/src/generic.js +1 -1
- package/dist/src/helper.js +2 -2
- package/dist/src/ime-lifecycle.js +9 -0
- package/dist/src/index.d.ts +593 -556
- package/dist/src/input-actions.js +4 -4
- package/dist/src/install-artifact.js +4 -4
- package/dist/src/install-artifact2.js +1 -1
- package/dist/src/install-source2.js +1 -1
- package/dist/src/interaction-snapshot.js +1 -1
- package/dist/src/interaction.d.ts +7 -4
- package/dist/src/interaction.js +1 -1
- package/dist/src/interactor.js +2 -2
- package/dist/src/interactor2.js +1 -1
- package/dist/src/internal/bin.js +1 -1
- package/dist/src/internal/daemon.js +14 -14
- package/dist/src/internal/png-worker.js +1 -1
- package/dist/src/lease-context.js +1 -1
- package/dist/src/lease.js +1 -1
- package/dist/src/limrun.d.ts +171 -190
- package/dist/src/limrun.js +1 -1
- package/dist/src/linux.js +1 -1
- package/dist/src/logcat.js +1 -1
- package/dist/src/manifest.js +1 -1
- package/dist/src/metro.d.ts +1 -1
- package/dist/src/parser.js +2 -0
- package/dist/src/path-resolution.js +1 -1
- package/dist/src/physical-device-control.js +1 -1
- package/dist/src/plist-xml.js +1 -0
- package/dist/src/plist.js +1 -1
- package/dist/src/png-worker-contract.js +1 -1
- package/dist/src/process-lock.js +1 -1
- package/dist/src/provider-limrun-runtime.js +1 -0
- package/dist/src/react-native.js +1 -1
- package/dist/src/record-trace.js +1 -1
- package/dist/src/register-builtins.js +1 -1
- package/dist/src/registry.js +22 -22
- package/dist/src/remote-config2.js +1 -1
- package/dist/src/reporting.js +7 -7
- package/dist/src/runner-client.js +1 -1
- package/dist/src/runner-disposal.js +1 -1
- package/dist/src/runner-lease.js +1 -1
- package/dist/src/runner-provider.js +1 -1
- package/dist/src/runner-sequence.js +1 -1
- package/dist/src/runtime-transport.js +1 -1
- package/dist/src/runtime.js +3 -3
- package/dist/src/screenshot-geometry.js +1 -0
- package/dist/src/screenshot-result.js +6 -0
- package/dist/src/sdk-android-adb.d.ts +5 -6
- package/dist/src/sdk-android-adb.js +3 -3
- package/dist/src/sdk-batch-runner.d.ts +2 -2
- package/dist/src/sdk-batch-runner.js +3 -3
- package/dist/src/sdk-contracts.d.ts +21 -25
- package/dist/src/sdk-contracts.js +2 -2
- package/dist/src/sdk-finders.d.ts +1 -1
- package/dist/src/sdk-finders.js +1 -1
- package/dist/src/sdk-io.js +1 -1
- package/dist/src/sdk-remote-config.d.ts +19 -4
- package/dist/src/sdk-remote-config.js +1 -1
- package/dist/src/sdk-selectors.js +1 -1
- package/dist/src/selector-runtime.js +1 -1
- package/dist/src/selector-vocabulary.js +1 -1
- package/dist/src/selectors.d.ts +1 -1
- package/dist/src/selectors.js +1 -1
- package/dist/src/server.js +2 -2
- package/dist/src/session-target-evidence.js +1 -1
- package/dist/src/session.js +8 -8
- package/dist/src/setting-state.js +1 -1
- package/dist/src/simulator.js +1 -1
- package/dist/src/snapshot.js +1 -1
- package/dist/src/snapshot2.js +1 -1
- package/dist/src/src.js +1 -0
- package/dist/src/tool-provider.js +3 -3
- package/dist/src/tool-provider2.js +1 -1
- package/dist/src/tool-provider3.js +1 -1
- package/dist/src/update-check.js +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/viewport-dimension.js +14 -0
- package/package.json +28 -19
- package/server.json +2 -2
- package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.2.apk.sha256 +0 -1
- package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.2.apk +0 -0
- package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.2.apk.sha256 +0 -1
- package/dist/src/android-input-method-overlays.js +0 -1
- package/dist/src/android-input-ownership.js +0 -1
- package/dist/src/apple-multitouch-support.js +0 -1
- package/dist/src/click-button.d.ts +0 -5
- package/dist/src/compatibility-policy.js +0 -1
- package/dist/src/gesture-plan-types.js +0 -1
- package/dist/src/gesture-plan.js +0 -1
- package/dist/src/launch-console.js +0 -1
- package/dist/src/recording-export-quality.js +0 -15
- package/dist/src/rolldown-runtime.js +0 -1
- package/dist/src/runtime2.js +0 -1
- package/dist/src/sdk-app-inventory.d.ts +0 -4
- package/dist/src/sdk-app-inventory.js +0 -1
- package/dist/src/snapshot-diagnostics.js +0 -6
- package/dist/src/xml.js +0 -2
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
019ebbda52976df5cff05f9e5a764c8d6e280be3640e8dea0a2d7cca6c25021f agent-device-android-ime-helper-0.20.4.apk
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-ime-helper",
|
|
3
|
-
"version": "0.20.
|
|
4
|
-
"assetName": "agent-device-android-ime-helper-0.20.
|
|
5
|
-
"sha256": "
|
|
3
|
+
"version": "0.20.4",
|
|
4
|
+
"assetName": "agent-device-android-ime-helper-0.20.4.apk",
|
|
5
|
+
"sha256": "019ebbda52976df5cff05f9e5a764c8d6e280be3640e8dea0a2d7cca6c25021f",
|
|
6
6
|
"packageName": "com.callstack.agentdevice.imehelper",
|
|
7
|
-
"versionCode":
|
|
7
|
+
"versionCode": 20004,
|
|
8
8
|
"serviceComponent": "com.callstack.agentdevice.imehelper/.TestInputMethodService",
|
|
9
9
|
"broadcastProtocol": "android-ime-helper-v1"
|
|
10
10
|
}
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
e3799afe5549776e047744efe46939a9146a4742a5328bb2e22f0b9d6abd2b52 agent-device-android-snapshot-helper-0.20.4.apk
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-snapshot-helper",
|
|
3
|
-
"version": "0.20.
|
|
4
|
-
"releaseTag": "v0.20.
|
|
5
|
-
"assetName": "agent-device-android-snapshot-helper-0.20.
|
|
3
|
+
"version": "0.20.4",
|
|
4
|
+
"releaseTag": "v0.20.4",
|
|
5
|
+
"assetName": "agent-device-android-snapshot-helper-0.20.4.apk",
|
|
6
6
|
"apkUrl": null,
|
|
7
|
-
"sha256": "
|
|
8
|
-
"checksumName": "agent-device-android-snapshot-helper-0.20.
|
|
7
|
+
"sha256": "e3799afe5549776e047744efe46939a9146a4742a5328bb2e22f0b9d6abd2b52",
|
|
8
|
+
"checksumName": "agent-device-android-snapshot-helper-0.20.4.apk.sha256",
|
|
9
9
|
"packageName": "com.callstack.agentdevice.snapshothelper",
|
|
10
|
-
"versionCode":
|
|
10
|
+
"versionCode": 20004,
|
|
11
11
|
"instrumentationRunner": "com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation",
|
|
12
12
|
"minSdk": 23,
|
|
13
13
|
"targetSdk": 36,
|
|
@@ -922,6 +922,49 @@ extension RunnerTests {
|
|
|
922
922
|
x: touchPoint.x,
|
|
923
923
|
y: touchPoint.y
|
|
924
924
|
)
|
|
925
|
+
var fallback: GestureFallback?
|
|
926
|
+
if command.synthesized == true {
|
|
927
|
+
let policyKind = SynthesizedGesturePolicyKind.coordinateTap
|
|
928
|
+
let context = synthesizedCoordinateContext(
|
|
929
|
+
app: activeApp,
|
|
930
|
+
policy: synthesizedGesturePolicy(policyKind)
|
|
931
|
+
)
|
|
932
|
+
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
|
|
933
|
+
synthesizedTapAt(
|
|
934
|
+
app: activeApp,
|
|
935
|
+
x: touchPoint.x,
|
|
936
|
+
y: touchPoint.y,
|
|
937
|
+
context: context
|
|
938
|
+
)
|
|
939
|
+
}
|
|
940
|
+
if case .performed = outcome {
|
|
941
|
+
logSynthesizedGesturePolicyDecision(
|
|
942
|
+
kind: policyKind,
|
|
943
|
+
context: context,
|
|
944
|
+
fallbackAttempted: false
|
|
945
|
+
)
|
|
946
|
+
if isTextEntry {
|
|
947
|
+
waitForTextEntryReadinessAfterTap(app: activeApp, element: element)
|
|
948
|
+
}
|
|
949
|
+
return gestureResponse(
|
|
950
|
+
message: match.usedNonHittableFallback
|
|
951
|
+
? "tapped via non-hittable coordinate fallback"
|
|
952
|
+
: "tapped",
|
|
953
|
+
timing: timing,
|
|
954
|
+
frame: .touch(touchFrame),
|
|
955
|
+
maestroNonHittableCoordinateFallbackUsed:
|
|
956
|
+
command.allowNonHittableCoordinateFallback == true
|
|
957
|
+
? match.usedNonHittableFallback
|
|
958
|
+
: nil
|
|
959
|
+
)
|
|
960
|
+
}
|
|
961
|
+
logSynthesizedGesturePolicyDecision(
|
|
962
|
+
kind: policyKind,
|
|
963
|
+
context: context,
|
|
964
|
+
fallbackAttempted: true
|
|
965
|
+
)
|
|
966
|
+
fallback = gestureFallback(strategy: "xctest-coordinate-tap", from: outcome)
|
|
967
|
+
}
|
|
925
968
|
let (timing, outcome) = performGesture(activeApp) {
|
|
926
969
|
if expectedPoint != nil || match.usedNonHittableFallback {
|
|
927
970
|
// Maestro compatibility: RN E2E backdoor controls can be 1x1 and
|
|
@@ -942,6 +985,7 @@ extension RunnerTests {
|
|
|
942
985
|
message: match.usedNonHittableFallback ? "tapped via non-hittable coordinate fallback" : "tapped",
|
|
943
986
|
timing: timing,
|
|
944
987
|
frame: .touch(touchFrame),
|
|
988
|
+
fallback: fallback,
|
|
945
989
|
maestroNonHittableCoordinateFallbackUsed:
|
|
946
990
|
command.allowNonHittableCoordinateFallback == true
|
|
947
991
|
? match.usedNonHittableFallback
|
|
@@ -954,7 +998,10 @@ extension RunnerTests {
|
|
|
954
998
|
var fallback: GestureFallback?
|
|
955
999
|
if command.synthesized == true {
|
|
956
1000
|
let policyKind = SynthesizedGesturePolicyKind.coordinateTap
|
|
957
|
-
let context = synthesizedCoordinateContext(
|
|
1001
|
+
let context = synthesizedCoordinateContext(
|
|
1002
|
+
app: activeApp,
|
|
1003
|
+
policy: synthesizedGesturePolicy(policyKind)
|
|
1004
|
+
)
|
|
958
1005
|
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
|
|
959
1006
|
synthesizedTapAt(app: activeApp, x: x, y: y, context: context)
|
|
960
1007
|
}
|
|
@@ -1045,7 +1092,10 @@ extension RunnerTests {
|
|
|
1045
1092
|
)
|
|
1046
1093
|
}
|
|
1047
1094
|
let scrollPolicyKind = SynthesizedGesturePolicyKind.scroll
|
|
1048
|
-
guard let scrollContext = synthesizedCoordinateContext(
|
|
1095
|
+
guard let scrollContext = synthesizedCoordinateContext(
|
|
1096
|
+
app: activeApp,
|
|
1097
|
+
policy: synthesizedGesturePolicy(scrollPolicyKind)
|
|
1098
|
+
) else {
|
|
1049
1099
|
return Response(
|
|
1050
1100
|
ok: false,
|
|
1051
1101
|
error: ErrorPayload(message: "scroll could not resolve a usable interaction frame")
|
|
@@ -1439,7 +1489,10 @@ extension RunnerTests {
|
|
|
1439
1489
|
var fallback: GestureFallback?
|
|
1440
1490
|
if synthesized {
|
|
1441
1491
|
let durationMs = min(max(durationMs ?? 250, 16), 10000)
|
|
1442
|
-
let context = synthesizedCoordinateContext(
|
|
1492
|
+
let context = synthesizedCoordinateContext(
|
|
1493
|
+
app: activeApp,
|
|
1494
|
+
policy: synthesizedGesturePolicy(synthesizedPolicyKind)
|
|
1495
|
+
)
|
|
1443
1496
|
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
|
|
1444
1497
|
synthesizedDragAt(
|
|
1445
1498
|
app: activeApp,
|
|
@@ -1495,7 +1548,7 @@ extension RunnerTests {
|
|
|
1495
1548
|
) -> Response? {
|
|
1496
1549
|
#if os(iOS)
|
|
1497
1550
|
let policy = synthesizedGesturePolicy(policyKind)
|
|
1498
|
-
let context = context ?? synthesizedCoordinateContext(policy: policy)
|
|
1551
|
+
let context = context ?? synthesizedCoordinateContext(app: activeApp, policy: policy)
|
|
1499
1552
|
guard let plan = axFreeSynthesizedDragPlan(
|
|
1500
1553
|
app: activeApp,
|
|
1501
1554
|
x: x,
|
package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift
CHANGED
|
@@ -335,168 +335,6 @@ extension RunnerTests {
|
|
|
335
335
|
&& point.y <= frame.maxY + tolerance
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
-
func isKeyboardVisible(app: XCUIApplication) -> Bool {
|
|
339
|
-
return visibleKeyboardFrame(app: app) != nil
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
|
|
343
|
-
let wasVisible = isKeyboardVisible(app: app)
|
|
344
|
-
guard wasVisible else {
|
|
345
|
-
return (wasVisible: false, dismissed: false, visible: false)
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
#if os(tvOS)
|
|
349
|
-
_ = pressTvRemote(.menu)
|
|
350
|
-
sleepFor(0.2)
|
|
351
|
-
let visible = isKeyboardVisible(app: app)
|
|
352
|
-
return (wasVisible: true, dismissed: !visible, visible: visible)
|
|
353
|
-
#else
|
|
354
|
-
if tapKeyboardDismissControl(app: app) {
|
|
355
|
-
sleepFor(0.2)
|
|
356
|
-
let visible = isKeyboardVisible(app: app)
|
|
357
|
-
return (wasVisible: true, dismissed: !visible, visible: visible)
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
|
|
361
|
-
#endif
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
func pressKeyboardReturn(app: XCUIApplication) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
|
|
365
|
-
#if os(tvOS)
|
|
366
|
-
return (wasVisible: false, pressed: pressTvRemote(.select), visible: false)
|
|
367
|
-
#elseif os(iOS)
|
|
368
|
-
let wasVisible = isKeyboardVisible(app: app)
|
|
369
|
-
if tapKeyboardReturnControl(app: app) {
|
|
370
|
-
sleepFor(0.2)
|
|
371
|
-
return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
var typed = false
|
|
375
|
-
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
376
|
-
app.typeText(XCUIKeyboardKey.return.rawValue)
|
|
377
|
-
typed = true
|
|
378
|
-
})
|
|
379
|
-
if let exceptionMessage {
|
|
380
|
-
NSLog(
|
|
381
|
-
"AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_IGNORED_EXCEPTION=%@",
|
|
382
|
-
exceptionMessage
|
|
383
|
-
)
|
|
384
|
-
if let singleTarget = singleTextEntryElement(app: app) {
|
|
385
|
-
return pressKeyboardReturn(on: singleTarget, app: app, wasVisible: wasVisible)
|
|
386
|
-
}
|
|
387
|
-
return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
|
|
388
|
-
}
|
|
389
|
-
sleepFor(0.2)
|
|
390
|
-
return (wasVisible: wasVisible, pressed: typed, visible: isKeyboardVisible(app: app))
|
|
391
|
-
#else
|
|
392
|
-
return (wasVisible: false, pressed: false, visible: false)
|
|
393
|
-
#endif
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
private func pressKeyboardReturn(
|
|
397
|
-
on element: XCUIElement,
|
|
398
|
-
app: XCUIApplication,
|
|
399
|
-
wasVisible: Bool
|
|
400
|
-
) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
|
|
401
|
-
#if os(iOS)
|
|
402
|
-
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
403
|
-
element.tap()
|
|
404
|
-
element.typeText(XCUIKeyboardKey.return.rawValue)
|
|
405
|
-
})
|
|
406
|
-
if let exceptionMessage {
|
|
407
|
-
NSLog(
|
|
408
|
-
"AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TARGET_IGNORED_EXCEPTION=%@",
|
|
409
|
-
exceptionMessage
|
|
410
|
-
)
|
|
411
|
-
return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
|
|
412
|
-
}
|
|
413
|
-
sleepFor(0.2)
|
|
414
|
-
return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
|
|
415
|
-
#else
|
|
416
|
-
return (wasVisible: wasVisible, pressed: false, visible: false)
|
|
417
|
-
#endif
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? {
|
|
421
|
-
#if os(iOS)
|
|
422
|
-
let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) {
|
|
423
|
-
app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
|
|
424
|
-
guard element.exists else { return false }
|
|
425
|
-
switch element.elementType {
|
|
426
|
-
case .textField, .secureTextField, .searchField, .textView:
|
|
427
|
-
return true
|
|
428
|
-
default:
|
|
429
|
-
return false
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
return matches.count == 1 ? matches[0] : nil
|
|
434
|
-
#else
|
|
435
|
-
return nil
|
|
436
|
-
#endif
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
|
|
440
|
-
#if os(tvOS)
|
|
441
|
-
return false
|
|
442
|
-
#else
|
|
443
|
-
guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
|
|
444
|
-
return false
|
|
445
|
-
}
|
|
446
|
-
for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
|
|
447
|
-
let candidates = [
|
|
448
|
-
app.keyboards.buttons[label],
|
|
449
|
-
app.keyboards.keys[label],
|
|
450
|
-
app.keyboards.toolbars.buttons[label],
|
|
451
|
-
]
|
|
452
|
-
if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
|
|
453
|
-
hittable.tap()
|
|
454
|
-
return true
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
let toolbarButtonPredicate = NSPredicate(
|
|
458
|
-
format: "label == %@ OR identifier == %@",
|
|
459
|
-
label,
|
|
460
|
-
label
|
|
461
|
-
)
|
|
462
|
-
let toolbarButtons = app.descendants(matching: .button)
|
|
463
|
-
.matching(toolbarButtonPredicate)
|
|
464
|
-
.allElementsBoundByIndex
|
|
465
|
-
if let hittable = toolbarButtons.first(where: {
|
|
466
|
-
$0.exists && $0.isHittable && isKeyboardAccessoryControl($0, keyboardFrame: keyboardFrame)
|
|
467
|
-
}) {
|
|
468
|
-
hittable.tap()
|
|
469
|
-
return true
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
return false
|
|
473
|
-
#endif
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool {
|
|
477
|
-
#if os(iOS)
|
|
478
|
-
for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
|
|
479
|
-
let candidates = [
|
|
480
|
-
app.keyboards.buttons[label],
|
|
481
|
-
app.keyboards.keys[label],
|
|
482
|
-
]
|
|
483
|
-
if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
|
|
484
|
-
hittable.tap()
|
|
485
|
-
return true
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
#endif
|
|
489
|
-
return false
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
|
|
493
|
-
let frame = element.frame
|
|
494
|
-
guard !frame.isEmpty && !keyboardFrame.isEmpty else {
|
|
495
|
-
return false
|
|
496
|
-
}
|
|
497
|
-
return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80
|
|
498
|
-
}
|
|
499
|
-
|
|
500
338
|
private func readableText(for element: XCUIElement) -> String? {
|
|
501
339
|
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
502
340
|
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
@@ -779,7 +617,10 @@ extension RunnerTests {
|
|
|
779
617
|
)
|
|
780
618
|
}
|
|
781
619
|
let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
|
|
782
|
-
guard let context = context ?? synthesizedCoordinateContext(
|
|
620
|
+
guard let context = context ?? synthesizedCoordinateContext(
|
|
621
|
+
app: app,
|
|
622
|
+
policy: synthesizedGesturePolicy(.synthesizedDrag)
|
|
623
|
+
) else {
|
|
783
624
|
return .unsupported(
|
|
784
625
|
message: "synthesized coordinate drag could not resolve a finite screen frame",
|
|
785
626
|
hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
|
|
@@ -842,7 +683,10 @@ extension RunnerTests {
|
|
|
842
683
|
)
|
|
843
684
|
}
|
|
844
685
|
let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
|
|
845
|
-
guard let context = context ?? synthesizedCoordinateContext(
|
|
686
|
+
guard let context = context ?? synthesizedCoordinateContext(
|
|
687
|
+
app: app,
|
|
688
|
+
policy: synthesizedGesturePolicy(.coordinateTap)
|
|
689
|
+
) else {
|
|
846
690
|
return .unsupported(
|
|
847
691
|
message: "synthesized coordinate tap could not resolve a finite screen frame",
|
|
848
692
|
hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
|
|
@@ -1005,20 +849,6 @@ extension RunnerTests {
|
|
|
1005
849
|
#endif
|
|
1006
850
|
}
|
|
1007
851
|
|
|
1008
|
-
private func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
|
|
1009
|
-
#if os(iOS)
|
|
1010
|
-
return safely("KEYBOARD_FRAME") {
|
|
1011
|
-
let keyboard = app.keyboards.firstMatch
|
|
1012
|
-
guard keyboard.exists else { return nil }
|
|
1013
|
-
let keyboardFrame = keyboard.frame
|
|
1014
|
-
guard !keyboardFrame.isEmpty else { return nil }
|
|
1015
|
-
return keyboardFrame
|
|
1016
|
-
}
|
|
1017
|
-
#else
|
|
1018
|
-
return nil
|
|
1019
|
-
#endif
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
852
|
func axFreeSynthesizedDragPlan(
|
|
1023
853
|
app: XCUIApplication,
|
|
1024
854
|
x: Double,
|
|
@@ -1028,7 +858,10 @@ extension RunnerTests {
|
|
|
1028
858
|
context: SynthesizedCoordinateContext? = nil
|
|
1029
859
|
) -> SynthesizedDragPlan? {
|
|
1030
860
|
#if os(iOS)
|
|
1031
|
-
let context = context ?? synthesizedCoordinateContext(
|
|
861
|
+
let context = context ?? synthesizedCoordinateContext(
|
|
862
|
+
app: app,
|
|
863
|
+
policy: synthesizedGesturePolicy(.synthesizedDrag)
|
|
864
|
+
)
|
|
1032
865
|
guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite,
|
|
1033
866
|
let context
|
|
1034
867
|
else {
|
|
@@ -1068,11 +901,18 @@ extension RunnerTests {
|
|
|
1068
901
|
)
|
|
1069
902
|
}
|
|
1070
903
|
|
|
1071
|
-
func synthesizedCoordinateContext(
|
|
904
|
+
func synthesizedCoordinateContext(
|
|
905
|
+
app: XCUIApplication,
|
|
906
|
+
policy: SynthesizedGesturePolicy
|
|
907
|
+
) -> SynthesizedCoordinateContext? {
|
|
1072
908
|
#if os(iOS)
|
|
1073
909
|
let health = runnerAccessibilityHealth
|
|
1074
|
-
|
|
1075
|
-
|
|
910
|
+
let orientation = Int(
|
|
911
|
+
RunnerSynthesizedGesture.interfaceOrientation(forApplication: app)
|
|
912
|
+
)
|
|
913
|
+
guard let referenceFrame = orientedSynthesizedScreenshotReferenceFrame(
|
|
914
|
+
screenshotSize: XCUIScreen.main.screenshot().image.size,
|
|
915
|
+
interfaceOrientation: orientation
|
|
1076
916
|
) else {
|
|
1077
917
|
return nil
|
|
1078
918
|
}
|
|
@@ -1087,15 +927,35 @@ extension RunnerTests {
|
|
|
1087
927
|
#endif
|
|
1088
928
|
}
|
|
1089
929
|
|
|
1090
|
-
func
|
|
1091
|
-
|
|
930
|
+
func orientedSynthesizedScreenshotReferenceFrame(
|
|
931
|
+
screenshotSize: CGSize,
|
|
932
|
+
interfaceOrientation: Int
|
|
933
|
+
) -> CGRect? {
|
|
934
|
+
// Physical iOS screenshots can retain portrait dimensions after the interface rotates,
|
|
935
|
+
// while accessibility frames remain in the logical landscape coordinate space.
|
|
1092
936
|
guard screenshotSize.width.isFinite, screenshotSize.height.isFinite,
|
|
1093
937
|
screenshotSize.width > 0,
|
|
1094
938
|
screenshotSize.height > 0
|
|
1095
939
|
else {
|
|
1096
940
|
return nil
|
|
1097
941
|
}
|
|
1098
|
-
|
|
942
|
+
let isLandscape = interfaceOrientation == RunnerInterfaceOrientation.landscapeLeft
|
|
943
|
+
|| interfaceOrientation == RunnerInterfaceOrientation.landscapeRight
|
|
944
|
+
let isPortrait = interfaceOrientation == RunnerInterfaceOrientation.portrait
|
|
945
|
+
|| interfaceOrientation == RunnerInterfaceOrientation.portraitUpsideDown
|
|
946
|
+
let width: CGFloat
|
|
947
|
+
let height: CGFloat
|
|
948
|
+
if isLandscape {
|
|
949
|
+
width = max(screenshotSize.width, screenshotSize.height)
|
|
950
|
+
height = min(screenshotSize.width, screenshotSize.height)
|
|
951
|
+
} else if isPortrait {
|
|
952
|
+
width = min(screenshotSize.width, screenshotSize.height)
|
|
953
|
+
height = max(screenshotSize.width, screenshotSize.height)
|
|
954
|
+
} else {
|
|
955
|
+
width = screenshotSize.width
|
|
956
|
+
height = screenshotSize.height
|
|
957
|
+
}
|
|
958
|
+
return CGRect(x: 0, y: 0, width: width, height: height)
|
|
1099
959
|
}
|
|
1100
960
|
|
|
1101
961
|
func synthesizedFrameAvoidingKeyboardWhenAllowed(
|
package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
private enum KeyboardDismissObservationTiming {
|
|
4
|
+
static let timeout: TimeInterval = 2
|
|
5
|
+
// #1542: dismissing the keyboard can trigger the host app's own content-offset
|
|
6
|
+
// adjustment (e.g. a ScrollView correcting the inset it grew to keep a focused
|
|
7
|
+
// field above the keyboard). That adjustment is a separate, unsynchronized
|
|
8
|
+
// animation the keyboard's own `waitForNonExistence` above knows nothing about:
|
|
9
|
+
// the keyboard AX element can vanish well before the app settles. The very next
|
|
10
|
+
// command is frequently a synthesized, AX-free drag (scroll/gesture — kept
|
|
11
|
+
// AX-free so it still works under #1105-family AX degradation), which has no
|
|
12
|
+
// XCTest quiescence wait of its own, so it can land mid-animation and net to
|
|
13
|
+
// zero. These bounds cap the settle wait this trades in.
|
|
14
|
+
// A spring-driven content-offset correction can pass through a near-zero-
|
|
15
|
+
// velocity inflection (the top of an overshoot) that two adjacent samples
|
|
16
|
+
// alone cannot distinguish from true rest. Requiring 3 consecutive matching
|
|
17
|
+
// samples demands ~2 full sample intervals of actual stillness before
|
|
18
|
+
// stopping early, so a momentary pause mid-animation cannot look settled.
|
|
19
|
+
static let settleTimeout: TimeInterval = 2.0
|
|
20
|
+
static let settleSampleInterval: TimeInterval = 0.15
|
|
21
|
+
static let settleRequiredConsecutiveMatches: Int = 3
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
extension RunnerTests {
|
|
25
|
+
func isKeyboardVisible(app: XCUIApplication) -> Bool {
|
|
26
|
+
return visibleKeyboardFrame(app: app) != nil
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
|
|
30
|
+
let keyboard = app.keyboards.firstMatch
|
|
31
|
+
let wasVisible = isKeyboardVisible(app: app)
|
|
32
|
+
guard wasVisible else {
|
|
33
|
+
return (wasVisible: false, dismissed: false, visible: false)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#if os(tvOS)
|
|
37
|
+
_ = pressTvRemote(.menu)
|
|
38
|
+
sleepFor(0.2)
|
|
39
|
+
let visible = isKeyboardVisible(app: app)
|
|
40
|
+
return (wasVisible: true, dismissed: !visible, visible: visible)
|
|
41
|
+
#else
|
|
42
|
+
if tapKeyboardDismissControl(app: app) {
|
|
43
|
+
_ = keyboard.waitForNonExistence(timeout: KeyboardDismissObservationTiming.timeout)
|
|
44
|
+
waitForScreenshotStability(
|
|
45
|
+
timeout: KeyboardDismissObservationTiming.settleTimeout,
|
|
46
|
+
sampleInterval: KeyboardDismissObservationTiming.settleSampleInterval,
|
|
47
|
+
requiredConsecutiveMatches: KeyboardDismissObservationTiming.settleRequiredConsecutiveMatches
|
|
48
|
+
)
|
|
49
|
+
let visible = isKeyboardVisible(app: app)
|
|
50
|
+
return (wasVisible: true, dismissed: !visible, visible: visible)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
|
|
54
|
+
#endif
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// AX-free on purpose (screenshot bytes, not the accessibility tree) so it holds
|
|
58
|
+
// under the same AX degradation the synthesized gesture lane is built to survive.
|
|
59
|
+
// Bounded and self-terminating: returns as soon as `requiredConsecutiveMatches`
|
|
60
|
+
// consecutive samples match, so an already-settled screen (the common case)
|
|
61
|
+
// pays close to nothing. The stopping decision itself
|
|
62
|
+
// (`runnerScreenshotStabilitySettled`) is a pure function of the samples taken
|
|
63
|
+
// so far, so it is unit-testable without a real screenshot pipeline; this loop
|
|
64
|
+
// is the thin, untestable I/O shell around it.
|
|
65
|
+
private func waitForScreenshotStability(
|
|
66
|
+
timeout: TimeInterval,
|
|
67
|
+
sampleInterval: TimeInterval,
|
|
68
|
+
requiredConsecutiveMatches: Int
|
|
69
|
+
) {
|
|
70
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
71
|
+
var samples: [Data?] = [screenshotFingerprintForStabilityCheck()]
|
|
72
|
+
while Date() < deadline {
|
|
73
|
+
sleepFor(sampleInterval)
|
|
74
|
+
samples.append(screenshotFingerprintForStabilityCheck())
|
|
75
|
+
if runnerScreenshotStabilitySettled(samples, requiredConsecutiveMatches: requiredConsecutiveMatches) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private func screenshotFingerprintForStabilityCheck() -> Data? {
|
|
82
|
+
guard let image = captureRunnerFrame() else { return nil }
|
|
83
|
+
return runnerPngData(for: image)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func pressKeyboardReturn(app: XCUIApplication) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
|
|
87
|
+
#if os(tvOS)
|
|
88
|
+
return (wasVisible: false, pressed: pressTvRemote(.select), visible: false)
|
|
89
|
+
#elseif os(iOS)
|
|
90
|
+
let wasVisible = isKeyboardVisible(app: app)
|
|
91
|
+
if tapKeyboardReturnControl(app: app) {
|
|
92
|
+
sleepFor(0.2)
|
|
93
|
+
return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
var typed = false
|
|
97
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
98
|
+
app.typeText(XCUIKeyboardKey.return.rawValue)
|
|
99
|
+
typed = true
|
|
100
|
+
})
|
|
101
|
+
if let exceptionMessage {
|
|
102
|
+
NSLog(
|
|
103
|
+
"AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_IGNORED_EXCEPTION=%@",
|
|
104
|
+
exceptionMessage
|
|
105
|
+
)
|
|
106
|
+
if let singleTarget = singleTextEntryElement(app: app) {
|
|
107
|
+
return pressKeyboardReturn(on: singleTarget, app: app, wasVisible: wasVisible)
|
|
108
|
+
}
|
|
109
|
+
return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
|
|
110
|
+
}
|
|
111
|
+
sleepFor(0.2)
|
|
112
|
+
return (wasVisible: wasVisible, pressed: typed, visible: isKeyboardVisible(app: app))
|
|
113
|
+
#else
|
|
114
|
+
return (wasVisible: false, pressed: false, visible: false)
|
|
115
|
+
#endif
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
|
|
119
|
+
#if os(iOS)
|
|
120
|
+
return safely("KEYBOARD_FRAME") {
|
|
121
|
+
let keyboard = app.keyboards.firstMatch
|
|
122
|
+
guard keyboard.exists else { return nil }
|
|
123
|
+
let keyboardFrame = keyboard.frame
|
|
124
|
+
guard !keyboardFrame.isEmpty else { return nil }
|
|
125
|
+
return keyboardFrame
|
|
126
|
+
}
|
|
127
|
+
#else
|
|
128
|
+
return nil
|
|
129
|
+
#endif
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func pressKeyboardReturn(
|
|
133
|
+
on element: XCUIElement,
|
|
134
|
+
app: XCUIApplication,
|
|
135
|
+
wasVisible: Bool
|
|
136
|
+
) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
|
|
137
|
+
#if os(iOS)
|
|
138
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
139
|
+
element.tap()
|
|
140
|
+
element.typeText(XCUIKeyboardKey.return.rawValue)
|
|
141
|
+
})
|
|
142
|
+
if let exceptionMessage {
|
|
143
|
+
NSLog(
|
|
144
|
+
"AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TARGET_IGNORED_EXCEPTION=%@",
|
|
145
|
+
exceptionMessage
|
|
146
|
+
)
|
|
147
|
+
return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
|
|
148
|
+
}
|
|
149
|
+
sleepFor(0.2)
|
|
150
|
+
return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
|
|
151
|
+
#else
|
|
152
|
+
return (wasVisible: wasVisible, pressed: false, visible: false)
|
|
153
|
+
#endif
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? {
|
|
157
|
+
#if os(iOS)
|
|
158
|
+
let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) {
|
|
159
|
+
app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
|
|
160
|
+
guard element.exists else { return false }
|
|
161
|
+
switch element.elementType {
|
|
162
|
+
case .textField, .secureTextField, .searchField, .textView:
|
|
163
|
+
return true
|
|
164
|
+
default:
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return matches.count == 1 ? matches[0] : nil
|
|
170
|
+
#else
|
|
171
|
+
return nil
|
|
172
|
+
#endif
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
|
|
176
|
+
#if os(tvOS)
|
|
177
|
+
return false
|
|
178
|
+
#else
|
|
179
|
+
guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
|
|
183
|
+
let candidates = [
|
|
184
|
+
app.keyboards.buttons[label],
|
|
185
|
+
app.keyboards.keys[label],
|
|
186
|
+
app.keyboards.toolbars.buttons[label],
|
|
187
|
+
]
|
|
188
|
+
if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
|
|
189
|
+
hittable.tap()
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let toolbarButtonPredicate = NSPredicate(
|
|
194
|
+
format: "label == %@ OR identifier == %@",
|
|
195
|
+
label,
|
|
196
|
+
label
|
|
197
|
+
)
|
|
198
|
+
let toolbarButtons = app.descendants(matching: .button)
|
|
199
|
+
.matching(toolbarButtonPredicate)
|
|
200
|
+
.allElementsBoundByIndex
|
|
201
|
+
if let hittable = toolbarButtons.first(where: {
|
|
202
|
+
$0.exists && $0.isHittable && isKeyboardAccessoryControl($0, keyboardFrame: keyboardFrame)
|
|
203
|
+
}) {
|
|
204
|
+
hittable.tap()
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return false
|
|
209
|
+
#endif
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool {
|
|
213
|
+
#if os(iOS)
|
|
214
|
+
for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
|
|
215
|
+
let candidates = [
|
|
216
|
+
app.keyboards.buttons[label],
|
|
217
|
+
app.keyboards.keys[label],
|
|
218
|
+
]
|
|
219
|
+
if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
|
|
220
|
+
hittable.tap()
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
#endif
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
|
|
229
|
+
let frame = element.frame
|
|
230
|
+
guard !frame.isEmpty && !keyboardFrame.isEmpty else {
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// True once the `requiredConsecutiveMatches` most recent samples are all present
|
|
238
|
+
/// and byte-identical — i.e. the screen held still across that whole run of
|
|
239
|
+
/// polls, not just the last two. A spring-driven content-offset correction can
|
|
240
|
+
/// pass through a near-zero-velocity inflection that two adjacent samples alone
|
|
241
|
+
/// cannot distinguish from true rest; requiring a longer run of matches demands
|
|
242
|
+
/// real elapsed stillness before stopping early. `nil` entries (a screenshot
|
|
243
|
+
/// capture that failed) never count toward a match, so a flaky capture cannot
|
|
244
|
+
/// look like stability; the caller's bounded loop still terminates on its
|
|
245
|
+
/// deadline regardless.
|
|
246
|
+
func runnerScreenshotStabilitySettled(
|
|
247
|
+
_ samples: [Data?],
|
|
248
|
+
requiredConsecutiveMatches: Int
|
|
249
|
+
) -> Bool {
|
|
250
|
+
guard requiredConsecutiveMatches >= 2, samples.count >= requiredConsecutiveMatches else {
|
|
251
|
+
return false
|
|
252
|
+
}
|
|
253
|
+
let window = samples.suffix(requiredConsecutiveMatches)
|
|
254
|
+
guard let first = window.first, let firstData = first else { return false }
|
|
255
|
+
return window.allSatisfy { $0 == firstData }
|
|
256
|
+
}
|
|
257
|
+
|