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
@@ -1 +1,2 @@
#import "RunnerObjCExceptionCatcher.h"
#import "RunnerSynthesizedGesture.h"
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface RunnerSynthesizedGesture : NSObject

+ (NSString * _Nullable)synthesizeTransformWithApplication:(id)application
x:(double)x
y:(double)y
dx:(double)dx
dy:(double)dy
scale:(double)scale
degrees:(double)degrees
radius:(double)radius
durationMs:(double)durationMs;

@end

NS_ASSUME_NONNULL_END
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
#import "RunnerSynthesizedGesture.h"

#import <CoreGraphics/CoreGraphics.h>
#import <math.h>
#import <objc/message.h>

typedef NSInteger (*RunnerMsgSendInteger)(id, SEL);
typedef id (*RunnerMsgSendInitRecord)(id, SEL, NSString *, NSInteger);
typedef id (*RunnerMsgSendInitPath)(id, SEL, CGPoint, NSTimeInterval);
typedef void (*RunnerMsgSendPathMove)(id, SEL, CGPoint, NSTimeInterval);
typedef void (*RunnerMsgSendPathOffset)(id, SEL, NSTimeInterval);
typedef void (*RunnerMsgSendAddPath)(id, SEL, id);
typedef void (*RunnerMsgSendSetInteger)(id, SEL, NSInteger);
typedef BOOL (*RunnerMsgSendSynthesize)(id, SEL, NSError **);

typedef struct {
Class recordClass;
Class pathClass;
SEL initRecordSelector;
SEL addPathSelector;
SEL setTargetProcessIDSelector;
SEL synthesizeSelector;
SEL interfaceOrientationSelector;
SEL processIDSelector;
SEL initPathSelector;
SEL moveSelector;
SEL liftSelector;
} RunnerXCTestEventBridge;

static NSString * _Nullable RunnerResolveXCTestEventBridge(
id application,
RunnerXCTestEventBridge *bridge
);
static NSString * _Nullable RunnerRequireClass(Class cls, NSString *className);
static NSString * _Nullable RunnerRequireSelector(Class cls, SEL selector, NSString *selectorName);
static NSString * _Nullable RunnerRequireApplicationSelector(id application, SEL selector, NSString *selectorName);
static id RunnerPointerPath(
const RunnerXCTestEventBridge *bridge,
CGPoint start,
double x,
double y,
double dx,
double dy,
double scale,
double degrees,
double radius,
double durationMs,
double side
);
static CGPoint RunnerPointerPointAt(
double x,
double y,
double dx,
double dy,
double scale,
double degrees,
double baseRadius,
double t,
double side
);

@implementation RunnerSynthesizedGesture

+ (NSString * _Nullable)synthesizeTransformWithApplication:(id)application
x:(double)x
y:(double)y
dx:(double)dx
dy:(double)dy
scale:(double)scale
degrees:(double)degrees
radius:(double)radius
durationMs:(double)durationMs {
@try {
return [self trySynthesizeTransformWithApplication:application
x:x
y:y
dx:dx
dy:dy
scale:scale
degrees:degrees
radius:radius
durationMs:durationMs];
} @catch (NSException *exception) {
NSString *name = exception.name ?: @"NSException";
NSString *reason = exception.reason ?: @"private XCTest event synthesis failed";
return [NSString stringWithFormat:@"%@: %@", name, reason];
}
}

+ (NSString * _Nullable)trySynthesizeTransformWithApplication:(id)application
x:(double)x
y:(double)y
dx:(double)dx
dy:(double)dy
scale:(double)scale
degrees:(double)degrees
radius:(double)radius
durationMs:(double)durationMs {
RunnerXCTestEventBridge bridge;
NSString *missing = RunnerResolveXCTestEventBridge(application, &bridge);
if (missing != nil) {
return missing;
}

NSInteger interfaceOrientation =
((RunnerMsgSendInteger)objc_msgSend)(application, bridge.interfaceOrientationSelector);
NSInteger targetProcessID = ((RunnerMsgSendInteger)objc_msgSend)(application, bridge.processIDSelector);
if (targetProcessID <= 0) {
return @"private XCTest event synthesis unavailable: could not resolve target process ID";
}

id record = ((RunnerMsgSendInitRecord)objc_msgSend)(
[bridge.recordClass alloc],
bridge.initRecordSelector,
@"agent-device-transform",
interfaceOrientation
);
if (record == nil) {
return @"private XCTest event synthesis failed: could not create event record";
}
((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge.setTargetProcessIDSelector, targetProcessID);

double sides[] = {1.0, -1.0};
for (int index = 0; index < 2; index += 1) {
double side = sides[index];
id path = RunnerPointerPath(
&bridge,
RunnerPointerPointAt(x, y, dx, dy, scale, degrees, radius, 0.0, side),
x,
y,
dx,
dy,
scale,
degrees,
radius,
durationMs,
side
);
if (path == nil) {
return @"private XCTest event synthesis failed: could not create pointer path";
}
((RunnerMsgSendAddPath)objc_msgSend)(record, bridge.addPathSelector, path);
}

NSError *error = nil;
BOOL ok = ((RunnerMsgSendSynthesize)objc_msgSend)(record, bridge.synthesizeSelector, &error);
if (!ok) {
NSString *detail = error.localizedDescription ?: @"synthesizeWithError returned false";
return [NSString stringWithFormat:@"private XCTest event synthesis failed: %@", detail];
}
return nil;
}

static NSString * _Nullable RunnerResolveXCTestEventBridge(
id application,
RunnerXCTestEventBridge *bridge
) {
Class recordClass = NSClassFromString(@"XCSynthesizedEventRecord");
Class pathClass = NSClassFromString(@"XCPointerEventPath");
SEL initRecordSelector = NSSelectorFromString(@"initWithName:interfaceOrientation:");
SEL addPathSelector = NSSelectorFromString(@"addPointerEventPath:");
SEL setTargetProcessIDSelector = NSSelectorFromString(@"setTargetProcessID:");
SEL synthesizeSelector = NSSelectorFromString(@"synthesizeWithError:");
SEL interfaceOrientationSelector = NSSelectorFromString(@"interfaceOrientation");
SEL processIDSelector = NSSelectorFromString(@"processID");
SEL initPathSelector = NSSelectorFromString(@"initForTouchAtPoint:offset:");
SEL moveSelector = NSSelectorFromString(@"moveToPoint:atOffset:");
SEL liftSelector = NSSelectorFromString(@"liftUpAtOffset:");

NSString *missing = RunnerRequireClass(recordClass, @"XCSynthesizedEventRecord");
if (missing != nil) return missing;
missing = RunnerRequireClass(pathClass, @"XCPointerEventPath");
if (missing != nil) return missing;
missing = RunnerRequireSelector(recordClass, initRecordSelector, @"initWithName:interfaceOrientation:");
if (missing != nil) return missing;
missing = RunnerRequireSelector(recordClass, addPathSelector, @"addPointerEventPath:");
if (missing != nil) return missing;
missing = RunnerRequireSelector(recordClass, setTargetProcessIDSelector, @"setTargetProcessID:");
if (missing != nil) return missing;
missing = RunnerRequireSelector(recordClass, synthesizeSelector, @"synthesizeWithError:");
if (missing != nil) return missing;
missing = RunnerRequireSelector(pathClass, initPathSelector, @"initForTouchAtPoint:offset:");
if (missing != nil) return missing;
missing = RunnerRequireSelector(pathClass, moveSelector, @"moveToPoint:atOffset:");
if (missing != nil) return missing;
missing = RunnerRequireSelector(pathClass, liftSelector, @"liftUpAtOffset:");
if (missing != nil) return missing;
missing = RunnerRequireApplicationSelector(application, interfaceOrientationSelector, @"interfaceOrientation");
if (missing != nil) return missing;
missing = RunnerRequireApplicationSelector(application, processIDSelector, @"processID");
if (missing != nil) return missing;

*bridge = (RunnerXCTestEventBridge){
.recordClass = recordClass,
.pathClass = pathClass,
.initRecordSelector = initRecordSelector,
.addPathSelector = addPathSelector,
.setTargetProcessIDSelector = setTargetProcessIDSelector,
.synthesizeSelector = synthesizeSelector,
.interfaceOrientationSelector = interfaceOrientationSelector,
.processIDSelector = processIDSelector,
.initPathSelector = initPathSelector,
.moveSelector = moveSelector,
.liftSelector = liftSelector,
};
return nil;
}

static NSString * _Nullable RunnerRequireClass(Class cls, NSString *className) {
if (cls == Nil) {
return [NSString stringWithFormat:@"private XCTest event synthesis unavailable: missing %@", className];
}
return nil;
}

static NSString * _Nullable RunnerRequireSelector(Class cls, SEL selector, NSString *selectorName) {
if (![cls instancesRespondToSelector:selector]) {
return [NSString stringWithFormat:
@"private XCTest event synthesis unavailable: %@ missing %@",
NSStringFromClass(cls),
selectorName
];
}
return nil;
}

static NSString * _Nullable RunnerRequireApplicationSelector(
id application,
SEL selector,
NSString *selectorName
) {
if (![application respondsToSelector:selector]) {
return [NSString stringWithFormat:
@"private XCTest event synthesis unavailable: XCUIApplication missing %@",
selectorName
];
}
return nil;
}

static id RunnerPointerPath(
const RunnerXCTestEventBridge *bridge,
CGPoint start,
double x,
double y,
double dx,
double dy,
double scale,
double degrees,
double radius,
double durationMs,
double side
) {
id path =
((RunnerMsgSendInitPath)objc_msgSend)([bridge->pathClass alloc], bridge->initPathSelector, start, 0.0);
if (path == nil) {
return nil;
}

int frameCount = MAX(3, (int)(durationMs / 16.0));
NSTimeInterval durationSeconds = durationMs / 1000.0;
for (int index = 1; index <= frameCount; index += 1) {
double t = (double)index / (double)frameCount;
CGPoint point = RunnerPointerPointAt(x, y, dx, dy, scale, degrees, radius, t, side);
NSTimeInterval offset = durationSeconds * t;
((RunnerMsgSendPathMove)objc_msgSend)(path, bridge->moveSelector, point, offset);
}

((RunnerMsgSendPathOffset)objc_msgSend)(path, bridge->liftSelector, durationSeconds);
return path;
}

static CGPoint RunnerPointerPointAt(
double x,
double y,
double dx,
double dy,
double scale,
double degrees,
double baseRadius,
double t,
double side
) {
double centerX = x + dx * t;
double centerY = y + dy * t;
double startRadius = baseRadius / MAX(scale, 1.0);
double endRadius = baseRadius;
if (scale < 1.0) {
startRadius = baseRadius;
endRadius = baseRadius * scale;
}
double radius = startRadius + (endRadius - startRadius) * t;
double angle = (-M_PI_2) + (degrees * M_PI / 180.0) * t;
return CGPointMake(centerX + cos(angle) * radius * side, centerY + sin(angle) * radius * side);
}

@end
Comment thread
thymikee marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -1281,35 +1281,36 @@ extension RunnerTests {
durationMs: Double
) -> RunnerInteractionOutcome {
#if os(iOS)
let holdDuration = max(0.02, min(durationMs / 1000.0, 10.0) / 3.0)
let panOutcome = performCoordinateDrag(
app: app,
let target = interactionRoot(app: app)
if let message = RunnerSynthesizedGesture.synthesizeTransform(
withApplication: app,
x: x,
y: y,
x2: x + dx,
y2: y + dy,
holdDuration: holdDuration
)
guard case .performed = panOutcome else {
return panOutcome
}

let target = gestureElement(app: app, x: x, y: y)
target.pinch(withScale: CGFloat(scale), velocity: CGFloat(scale >= 1.0 ? 1.0 : -1.0))
return performCoordinateRotateGesture(
app: app,
dx: dx,
dy: dy,
scale: scale,
degrees: degrees,
x: x,
y: y,
velocity: degrees >= 0 ? 1.0 : -1.0
)
radius: transformGestureRadius(frame: target.frame, scale: scale),
durationMs: durationMs
) {
return .unsupported(message)
}
return .performed
Comment thread
thymikee marked this conversation as resolved.
#elseif os(tvOS)
return .unsupported("transformGesture is not supported on tvOS")
#else
return .unsupported("transformGesture is not supported on macOS")
#endif
}

private func transformGestureRadius(frame: CGRect, scale: Double) -> Double {
let shorterSide = Double(min(frame.width, frame.height))
let frameRadius = shorterSide * 0.20
let minimumEndRadius = shorterSide * 0.08
let scaleAdjustedRadius = scale < 1.0 ? max(frameRadius, minimumEndRadius / scale) : frameRadius
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")
Expand Down Expand Up @@ -1361,21 +1362,6 @@ extension RunnerTests {
#endif
}

#if os(iOS)
private func gestureElement(app: XCUIApplication, x: Double, y: Double) -> XCUIElement {
let point = CGPoint(x: x, y: y)
let matches = app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
element.exists && element.frame.contains(point) && !element.frame.isEmpty
}
if let smallest = matches.min(by: { left, right in
(left.frame.width * left.frame.height) < (right.frame.width * right.frame.height)
}) {
return smallest
}
return interactionRoot(app: app)
}
#endif

private func interactionRoot(app: XCUIApplication) -> XCUIElement {
let windows = app.windows.allElementsBoundByIndex
if let window = windows.first(where: { $0.exists && !$0.frame.isEmpty }) {
Expand Down
Loading
Loading