Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -223,15 +223,41 @@ extension RunnerTests {
guard let x = command.x, let y = command.y else {
return Response(ok: false, error: ErrorPayload(message: "longPress requires x and y"))
}
let duration = (command.durationMs ?? 800) / 1000.0
let durationMs = command.durationMs ?? 800
let fallback: GestureFallback?
#if os(iOS)
let policyKind = SynthesizedGesturePolicyKind.coordinateLongPress
let context = synthesizedCoordinateContext(
app: activeApp,
policy: synthesizedGesturePolicy(policyKind)
)
switch performSynthesizedGesture(activeApp, kind: policyKind, context: context, synthesize: {
synthesizedLongPressAt(app: activeApp, x: x, y: y, durationMs: durationMs, context: context)
}) {
case .performed(let timing):
let touchFrame = context.map { touchVisualizationFrame(x: x, y: y, context: $0) }
return gestureResponse(message: "long pressed", timing: timing, frame: .touch(touchFrame))
case .xctestFallback(let message, let hint):
fallback = GestureFallback(strategy: "xctest-coordinate-long-press", message: message, hint: hint)
case .refused(_, let message, let hint):
return unsupportedResponse(message: message, hint: hint)
}
#else
fallback = nil
#endif
let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
let (timing, outcome) = performGesture(activeApp) {
longPressAt(app: activeApp, x: x, y: y, duration: duration)
longPressAt(app: activeApp, x: x, y: y, duration: durationMs / 1000.0)
}
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(message: "long pressed", timing: timing, frame: .touch(touchFrame))
return gestureResponse(
message: "long pressed",
timing: timing,
frame: .touch(touchFrame),
fallback: fallback
)
case .drag:
guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
Expand Down Expand Up @@ -644,7 +670,7 @@ extension RunnerTests {
return plannedGestureResponse(plan: plan, timing: timing, outcome: outcome)
}
case .gestureViewport:
let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
let frame = gestureViewportFrame(app: activeApp)
guard !frame.isNull, !frame.isInfinite, !frame.isEmpty else {
return Response(ok: false, error: ErrorPayload(code: "COMMAND_FAILED", message: "Active app interaction viewport is unavailable"))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@ extension RunnerTests {
}
}

// Touch frame resolves from the first step's coords so recording-gestures works unchanged.
let synthesizedContext = synthesizedSequenceCoordinateContext(steps: steps, app: activeApp)

// Touch frame resolves from the first step's coords so recording-gestures works unchanged. A
// synthesized context already names the frame, so the app tree is not read for it again.
let firstStep = steps[0]
let firstFrame = (firstStep.x != nil && firstStep.y != nil)
? resolvedTouchVisualizationFrame(app: activeApp, x: firstStep.x!, y: firstStep.y!)
? synthesizedContext.map { touchVisualizationFrame(x: firstStep.x!, y: firstStep.y!, context: $0) }
?? resolvedTouchVisualizationFrame(app: activeApp, x: firstStep.x!, y: firstStep.y!)
: nil

let synthesizedContext = synthesizedSequenceCoordinateContext(steps: steps, app: activeApp)

let execution = assembleSequenceExecution(steps: steps) { _, step in
performSequenceStep(step, activeApp: activeApp, synthesizedContext: synthesizedContext)
}
Expand Down Expand Up @@ -145,9 +147,17 @@ extension RunnerTests {
) -> SequenceStepOutcome {
let x = step.x ?? 0
let y = step.y ?? 0
let longPressDurationMs = min(max(step.durationMs ?? 800, 16), 10000)
if let policyKind = synthesizedPolicyKind(forSequenceStep: step) {
switch performSynthesizedGesture(activeApp, kind: policyKind, context: synthesizedContext, synthesize: {
synthesizedTapAt(app: activeApp, x: x, y: y, context: synthesizedContext)
synthesizedSequenceStep(
policyKind,
app: activeApp,
x: x,
y: y,
longPressDurationMs: longPressDurationMs,
context: synthesizedContext
)
}) {
case .performed(let timing):
return finishedSequenceStep(step, timing: timing, outcome: .performed)
Expand All @@ -167,15 +177,39 @@ extension RunnerTests {
// doubleTapAt per step, matching the behavior of the retired tapSeries doubleTap path.
return doubleTapAt(app: activeApp, x: x, y: y)
case "longPress":
let duration = min(max(step.durationMs ?? 800, 16), 10000) / 1000.0
return longPressAt(app: activeApp, x: x, y: y, duration: duration)
return longPressAt(app: activeApp, x: x, y: y, duration: longPressDurationMs / 1000.0)
default:
return tapAt(app: activeApp, x: x, y: y)
}
}
return finishedSequenceStep(step, timing: timing, outcome: outcome)
}

@MainActor
private func synthesizedSequenceStep(
_ policyKind: SynthesizedGesturePolicyKind,
app: XCUIApplication,
x: Double,
y: Double,
longPressDurationMs: Double,
context: SynthesizedCoordinateContext?
) -> RunnerInteractionOutcome? {
switch policyKind {
#if os(iOS)
case .coordinateLongPress:
return synthesizedLongPressAt(
app: app,
x: x,
y: y,
durationMs: longPressDurationMs,
context: context
)
#endif
default:
return synthesizedTapAt(app: app, x: x, y: y, context: context)
}
}

private func finishedSequenceStep(
_ step: SequenceStep,
timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ enum SynthesizedFallbackPolicy: String, Equatable, Hashable {

enum SynthesizedGesturePolicyKind: String, Equatable, Hashable {
case coordinateTap
case coordinateLongPress
case scroll
case synthesizedDrag
}
Expand Down Expand Up @@ -85,6 +86,14 @@ func synthesizedGesturePolicy(_ kind: SynthesizedGesturePolicyKind) -> Synthesiz
keyboardPolicy: .never,
fallbackPolicy: .xctestCoordinateAllowed
)
case .coordinateLongPress:
// XCTest's coordinate press resolves the app frame through the tree. On a tree XCTest cannot
// serialize, that read fails after its retries and the press raises an exception that ends the
// runner test, so the fallback needs a tree that answers.
return SynthesizedGesturePolicy(
keyboardPolicy: .never,
fallbackPolicy: .xctestCoordinateWhenAccessibilityAvailable
)
case .scroll:
// Scroll places a viewport-center-symmetric swipe, so it cannot tell a keyboard-struck swipe
// from a scroll that reached the edge without reading the live keyboard frame (#2500). The
Expand All @@ -108,9 +117,19 @@ func shouldProbeCoordinateTapTextInput(xCTestChannelPenalized: Bool) -> Bool {
!xCTestChannelPenalized
}

/// A synthesized `tap` step in a `sequence` is a standalone coordinate tap and follows its policy.
/// A synthesized `tap` step in a `sequence` is a standalone coordinate tap and follows its policy. On
/// iOS a `longPress` step is a standalone long press, which is always synthesized.
func synthesizedPolicyKind(forSequenceStep step: SequenceStep) -> SynthesizedGesturePolicyKind? {
step.synthesized == true && step.kind == "tap" ? .coordinateTap : nil
switch step.kind {
case "tap":
return step.synthesized == true ? .coordinateTap : nil
#if os(iOS)
case "longPress":
return .coordinateLongPress
#endif
default:
return nil
}
}

/// A synthesized gesture's result for its call site, which owns the XCTest coordinate gesture.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,63 @@ extension RunnerTests {
#endif
}

#if os(iOS)
/// A long press is one stationary contact held for `durationMs`, synthesized like a tap: it needs
/// no app tree, so on a tree XCTest cannot serialize it lands through the sole-screen route.
@MainActor
func synthesizedLongPressAt(
app: XCUIApplication,
x: Double,
y: Double,
durationMs: Double,
context: SynthesizedCoordinateContext?
) -> RunnerInteractionOutcome {
guard x.isFinite, y.isFinite, durationMs.isFinite, durationMs > 0 else {
return .unsupported(
message: "synthesized coordinate long press requires finite coordinates and a positive duration",
hint: "Retry with finite x and y values and a positive durationMs."
)
}
guard let context else {
return .unsupported(
message: "synthesized coordinate long press could not resolve an app window with a finite screen frame",
hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
)
}
let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
let point = CoordinateSpaceRotation.native(
point: CGPoint(x: x, y: y),
in: context.referenceFrame,
interfaceOrientation: orientation
)
logSynthesizedDispatch(
kind: "longPress",
start: point,
end: nil,
referenceFrame: context.referenceFrame,
orientation: orientation
)
let samples: [[String: NSNumber]] = [0, durationMs].map { offsetMs in
[
"x": NSNumber(value: Double(point.x)),
"y": NSNumber(value: Double(point.y)),
"offsetMs": NSNumber(value: offsetMs),
]
}
if let message = RunnerSynthesizedGesture.synthesizeGesture(
withApplication: app,
resolvedWindow: context.resolvedWindow,
pointerSamples: [samples]
) {
return .unsupported(
message: message,
hint: "Private XCTest event synthesis is required for an AX-free long press on iOS; update Xcode if this persists."
)
}
return .performed
}
#endif

func keyboardAvoidingDragPoints(
app: XCUIApplication,
x: Double,
Expand Down Expand Up @@ -239,6 +296,21 @@ extension RunnerTests {
)
}

/// The touch visualization frame of a synthesized gesture, read from its context instead of the
/// app tree.
func touchVisualizationFrame(
x: Double,
y: Double,
context: SynthesizedCoordinateContext
) -> TouchVisualizationFrame {
TouchVisualizationFrame(
x: Double(context.referenceFrame.minX) + x,
y: Double(context.referenceFrame.minY) + y,
referenceWidth: Double(context.referenceFrame.width),
referenceHeight: Double(context.referenceFrame.height)
)
}

func resolvedDragVisualizationFrame(
app: XCUIApplication,
x: Double,
Expand Down Expand Up @@ -269,6 +341,27 @@ extension RunnerTests {
return CGRect(x: 0, y: 0, width: 0, height: 0)
}

/// The viewport the portable gesture planner lays pointer paths in: the frame of the synthesized
/// context its record is routed through, so a tree with no window on an iPhone with one screen
/// plans on that screen, the same route a synthesized tap or scroll takes.
@MainActor
func gestureViewportFrame(app: XCUIApplication) -> CGRect {
#if os(iOS)
guard let context = synthesizedCoordinateContext(
app: app,
policy: synthesizedGesturePolicy(.synthesizedDrag)
) else {
return frameAvoidingKeyboard(app: app, frame: app.frame)
}
// The sole-screen route means the windows query just failed, so a keyboard probe would too.
return context.resolvedWindow == nil
? context.referenceFrame
: frameAvoidingKeyboard(app: app, frame: context.referenceFrame)
#else
return resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
#endif
}

private func frameAvoidingKeyboard(app: XCUIApplication, frame: CGRect) -> CGRect {
#if os(iOS)
guard let keyboardFrame = visibleKeyboardFrame(app: app), !frame.isEmpty else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ extension RunnerTests {
fallbackPolicy: .xctestCoordinateAllowed
)
)
XCTAssertEqual(
synthesizedGesturePolicy(.coordinateLongPress),
SynthesizedGesturePolicy(
keyboardPolicy: .never,
fallbackPolicy: .xctestCoordinateWhenAccessibilityAvailable
)
)
XCTAssertEqual(
synthesizedGesturePolicy(.scroll),
SynthesizedGesturePolicy(
Expand All @@ -103,7 +110,19 @@ extension RunnerTests {
XCTAssertEqual(synthesizedPolicyKind(forSequenceStep: sequenceStep("tap", synthesized: true)), .coordinateTap)
XCTAssertNil(synthesizedPolicyKind(forSequenceStep: sequenceStep("tap", synthesized: nil)))
XCTAssertNil(synthesizedPolicyKind(forSequenceStep: sequenceStep("doubleTap", synthesized: true)))
XCTAssertNil(synthesizedPolicyKind(forSequenceStep: sequenceStep("longPress", synthesized: true)))
}

func testSequenceLongPressStepsTakeTheLongPressPolicyWithoutARequestFlag() {
for synthesized in [nil, true] as [Bool?] {
#if os(iOS)
XCTAssertEqual(
synthesizedPolicyKind(forSequenceStep: sequenceStep("longPress", synthesized: synthesized)),
.coordinateLongPress
)
#else
XCTAssertNil(synthesizedPolicyKind(forSequenceStep: sequenceStep("longPress", synthesized: synthesized)))
#endif
}
}

@MainActor
Expand Down Expand Up @@ -144,6 +163,8 @@ extension RunnerTests {
(.synthesizedDrag, unavailable, "refused"),
(.synthesizedDrag, unknown, "xctestFallback"),
(.coordinateTap, unavailable, "xctestFallback"),
(.coordinateLongPress, unavailable, "refused"),
(.coordinateLongPress, unknown, "xctestFallback"),
]
for (kind, context, expected) in cases {
let attempt = performSynthesizedGesture(app, kind: kind, context: context, synthesize: failedSynthesis)
Expand Down
Loading
Loading