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 @@ -17,10 +17,10 @@ extension RunnerTests {
switch outcome {
case .performed:
return nil
case .unsupported(let message):
case .unsupported(let message, let hint):
return Response(
ok: false,
error: ErrorPayload(code: "UNSUPPORTED_OPERATION", message: message)
error: ErrorPayload(code: "UNSUPPORTED_OPERATION", message: message, hint: hint)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1604,13 +1604,51 @@ extension RunnerTests {
degrees: 0,
durationMs: 300
)
#elseif os(tvOS)
return .unsupported(
message: "pinch is not supported on tvOS",
hint: "tvOS has no touch input; pinch requires a touchscreen (run on iOS)."
)
#else
return performCoordinatePinch(app: app, scale: scale, x: x, y: y)
return .unsupported(
message: "pinch is not supported on macOS",
hint: "macOS automation has no multi-touch input; pinch requires a touchscreen (run on iOS)."
)
#endif
}

func rotateGesture(app: XCUIApplication, degrees: Double, x: Double?, y: Double?, velocity: Double) -> RunnerInteractionOutcome {
return performCoordinateRotateGesture(app: app, degrees: degrees, x: x, y: y, velocity: velocity)
#if os(iOS)
// Drive the two-finger XCTest synthesis path (the same one pinch/transformGesture use, #634)
// with zero translation/scale so React Native's rotation recognizer actually fires. The native
// XCUIElement.rotate(withVelocity:) injects a single synthetic rotation that RN's gesture
// handler does not read reliably — the same class of problem #629/#634 fixed for pinch.
// velocity is unused on iOS (synthesis speed is governed by durationMs); the wire contract
// keeps it for compatibility and direction is carried entirely by the sign of `degrees`.
let frame = interactionRoot(app: app).frame
let centerX = x ?? Double(frame.midX)
let centerY = y ?? Double(frame.midY)
return transformGesture(
app: app,
x: centerX,
y: centerY,
dx: 0,
dy: 0,
scale: 1,
degrees: degrees,
durationMs: 300
)
#elseif os(tvOS)
return .unsupported(
message: "rotate-gesture is not supported on tvOS",
hint: "tvOS has no touch input; rotation gestures require a touchscreen (run on iOS)."
)
#else
return .unsupported(
message: "rotate-gesture is not supported on macOS",
hint: "macOS automation has no multi-touch input; rotation gestures require a touchscreen (run on iOS)."
)
#endif
}

func transformGesture(
Expand All @@ -1636,13 +1674,22 @@ extension RunnerTests {
radius: transformGestureRadius(frame: target.frame, scale: scale),
durationMs: durationMs
) {
return .unsupported(message)
return .unsupported(
message: message,
hint: "This gesture uses private XCTest event-synthesis APIs; rebuild the runner with a supported Xcode (these APIs can change across Xcode versions)."
)
}
return .performed
#elseif os(tvOS)
return .unsupported("transformGesture is not supported on tvOS")
return .unsupported(
message: "transformGesture is not supported on tvOS",
hint: "tvOS has no touch input; transform gestures require a touchscreen (run on iOS)."
)
#else
return .unsupported("transformGesture is not supported on macOS")
return .unsupported(
message: "transformGesture is not supported on macOS",
hint: "macOS automation has no multi-touch input; transform gestures require a touchscreen (run on iOS)."
)
#endif
}

Expand All @@ -1654,57 +1701,6 @@ extension RunnerTests {
return min(max(scaleAdjustedRadius, 48.0), shorterSide * 0.35)
}

private func performCoordinatePinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
#if os(tvOS)
return .unsupported("pinch is not supported on tvOS")
#else
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app

// Use double-tap + drag gesture for reliable map zoom
// Zoom in (scale > 1): tap then drag UP
// Zoom out (scale < 1): tap then drag DOWN

// Determine center point (use provided x/y or screen center)
let centerX = x.map { $0 / target.frame.width } ?? 0.5
let centerY = y.map { $0 / target.frame.height } ?? 0.5
let center = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: centerY))

// Calculate drag distance based on scale (clamped to reasonable range)
// Larger scale = more drag distance
let dragAmount: CGFloat
if scale > 1.0 {
// Zoom in: drag up (negative Y direction in normalized coords)
dragAmount = min(0.4, CGFloat(scale - 1.0) * 0.2)
} else {
// Zoom out: drag down (positive Y direction)
dragAmount = min(0.4, CGFloat(1.0 - scale) * 0.4)
}

let endY = scale > 1.0 ? (centerY - Double(dragAmount)) : (centerY + Double(dragAmount))
let endPoint = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: max(0.1, min(0.9, endY))))

// Tap first (first tap of double-tap)
center.tap()

// Immediately press and drag (second tap + drag)
center.press(forDuration: 0.05, thenDragTo: endPoint)
return .performed
#endif
}

private func performCoordinateRotateGesture(app: XCUIApplication, degrees: Double, x: Double?, y: Double?, velocity: Double) -> RunnerInteractionOutcome {
#if os(iOS)
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
let radians = CGFloat(degrees * .pi / 180.0)
target.rotate(radians, withVelocity: CGFloat(velocity))
return .performed
#elseif os(tvOS)
return .unsupported("rotate-gesture is not supported on tvOS")
#else
return .unsupported("rotate-gesture is not supported on macOS")
#endif
}

private func interactionRoot(app: XCUIApplication) -> XCUIElement {
let windows = app.windows.allElementsBoundByIndex
if let window = windows.first(where: { $0.exists && !$0.frame.isEmpty }) {
Expand All @@ -1715,7 +1711,10 @@ extension RunnerTests {

private func performCoordinateTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
#if os(tvOS)
return .unsupported("coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
return .unsupported(
message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
)
#else
interactionCoordinate(app: app, x: x, y: y).tap()
return .performed
Expand All @@ -1724,7 +1723,10 @@ extension RunnerTests {

private func performCoordinateDoubleTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
#if os(tvOS)
return .unsupported("coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
return .unsupported(
message: "coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
)
#else
interactionCoordinate(app: app, x: x, y: y).doubleTap()
return .performed
Expand All @@ -1733,7 +1735,10 @@ extension RunnerTests {

private func performCoordinateLongPress(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
#if os(tvOS)
return .unsupported("coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element")
return .unsupported(
message: "coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element",
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then long-select it."
)
#else
interactionCoordinate(app: app, x: x, y: y).press(forDuration: duration)
return .performed
Expand All @@ -1749,7 +1754,10 @@ extension RunnerTests {
holdDuration: TimeInterval
) -> RunnerInteractionOutcome {
#if os(tvOS)
return .unsupported("coordinate drag is not supported on tvOS")
return .unsupported(
message: "coordinate drag is not supported on tvOS",
hint: "tvOS has no coordinate input; use remote-driven swipe/scroll to move focus instead."
)
#else
let start = interactionCoordinate(app: app, x: x, y: y)
let end = interactionCoordinate(app: app, x: x2, y: y2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import XCTest

enum RunnerInteractionOutcome {
case performed
case unsupported(String)
/// A capability/state gap, surfaced to the caller as an UNSUPPORTED_OPERATION error.
/// `hint` is an optional actionable next step (mapped to ErrorPayload.hint).
case unsupported(message: String, hint: String?)
}

enum TvRemoteButton {
Expand Down Expand Up @@ -85,7 +87,10 @@ extension RunnerTests {
func selectFocusedTvElement(app: XCUIApplication, point: CGPoint, action: String) -> RunnerInteractionOutcome? {
#if os(tvOS)
guard let focused = focusedTvElement(app: app), !focused.frame.isEmpty, focused.frame.contains(point) else {
return .unsupported("\(action) is supported on tvOS only when the requested point is inside the focused element")
return .unsupported(
message: "\(action) is supported on tvOS only when the requested point is inside the focused element",
hint: "Move focus with swipe or scroll until the target is focused, then retry."
)
}
_ = pressTvRemote(.select)
return .performed
Expand All @@ -97,7 +102,10 @@ extension RunnerTests {
func longSelectFocusedTvElement(app: XCUIApplication, point: CGPoint, duration: TimeInterval) -> RunnerInteractionOutcome? {
#if os(tvOS)
guard let focused = focusedTvElement(app: app), !focused.frame.isEmpty, focused.frame.contains(point) else {
return .unsupported("long press is supported on tvOS only when the requested point is inside the focused element")
return .unsupported(
message: "long press is supported on tvOS only when the requested point is inside the focused element",
hint: "Move focus with swipe or scroll until the target is focused, then retry."
)
}
_ = pressTvRemote(.select, duration: duration)
return .performed
Expand All @@ -108,7 +116,10 @@ extension RunnerTests {

private func performElementTap(_ element: XCUIElement) -> RunnerInteractionOutcome {
#if os(tvOS)
return .unsupported("element tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
return .unsupported(
message: "element tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
hint: "Use swipe/scroll to move focus to the target, then select it; tvOS has no coordinate tap."
)
#else
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
element.tap()
Expand All @@ -118,7 +129,7 @@ extension RunnerTests {
if isPostTapElementDisappearance(exceptionMessage) {
return .performed
}
return .unsupported("element tap failed: \(exceptionMessage)")
return .unsupported(message: "element tap failed: \(exceptionMessage)", hint: nil)
}
return .performed
#endif
Expand All @@ -132,7 +143,10 @@ extension RunnerTests {
private func selectFocusedTvElement(app: XCUIApplication, element: XCUIElement, action: String) -> RunnerInteractionOutcome? {
#if os(tvOS)
guard tvFocusedElementMatches(app: app, target: element) else {
return .unsupported("\(action) is supported on tvOS only when the requested element is focused")
return .unsupported(
message: "\(action) is supported on tvOS only when the requested element is focused",
hint: "Move focus to the target element first, then retry."
)
}
_ = pressTvRemote(.select)
return .performed
Expand Down
37 changes: 34 additions & 3 deletions src/core/__tests__/capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { isCommandSupportedOnDevice } from '../capabilities.ts';
import { isCommandSupportedOnDevice, unsupportedHintForDevice } from '../capabilities.ts';
import type { DeviceInfo } from '../../utils/device.ts';

const iosSimulator: DeviceInfo = {
Expand Down Expand Up @@ -74,7 +74,8 @@ test('device capability matrix stays consistent across shared command groups', (
{ device: iosSimulator, expected: true, label: 'on iOS sim' },
{ device: iosDevice, expected: false, label: 'on iOS device' },
{ device: androidDevice, expected: true, label: 'on Android' },
{ device: macOsDevice, expected: true, label: 'on macOS' },
{ device: macOsDevice, expected: false, label: 'on macOS' },
{ device: tvOsSimulator, expected: false, label: 'on tvOS simulator' },
],
},
{
Expand Down Expand Up @@ -233,7 +234,6 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only
'logs',
'network',
'open',
'pinch',
'perf',
'press',
'record',
Expand All @@ -255,6 +255,7 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only
'home',
'install',
'install-from-source',
'pinch',
'push',
'reinstall',
'rotate',
Expand Down Expand Up @@ -359,3 +360,33 @@ test('unknown commands default to supported', () => {
assert.equal(isCommandSupportedOnDevice('some-future-cmd', androidDevice), true);
assert.equal(isCommandSupportedOnDevice('some-future-cmd', linuxDevice), true);
});

test('synthesis gestures carry an actionable unsupported hint at admission', () => {
// macOS / tvOS / physical iOS are rejected at admission; the hint redirects to where the
// two-finger synthesis path actually works, so callers do not just see a bare "not supported".
for (const command of ['pinch', 'rotate-gesture', 'transform-gesture']) {
assert.match(
unsupportedHintForDevice(command, macOsDevice) ?? '',
/multi-touch/i,
`${command} macOS hint`,
);
assert.match(
unsupportedHintForDevice(command, tvOsSimulator) ?? '',
/touch/i,
`${command} tvOS hint`,
);
assert.match(
unsupportedHintForDevice(command, iosDevice) ?? '',
/simulator/i,
`${command} iOS device hint`,
);
// Where the gesture IS supported there is nothing to hint.
assert.equal(
unsupportedHintForDevice(command, iosSimulator),
undefined,
`${command} iOS sim (supported) hint`,
);
}
// Commands without a hint hook return undefined (admission keeps its generic message).
assert.equal(unsupportedHintForDevice('tap', macOsDevice), undefined);
});
Loading
Loading