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,9 +1,13 @@
#import "RunnerObjCExceptionCatcher.h"
#import "RunnerAXSnapshotBridge.h"
#import "RunnerSimulatorEdgeTouch.h"
#import "RunnerSynthesizedGesture.h"
#import "RunnerSynthesizedTextEntry.h"
#import "RunnerXCTestEventBridge.h"
// The packaged runner omits UnitTests/. OTHER_CFLAGS defines do not reach this header.
#if __has_include("UnitTests/RunnerAXModalScopeCapture.h")
#import "UnitTests/RunnerAXModalScopeCapture.h"
#endif
#if __has_include("UnitTests/RunnerEdgeContactTesting.h")
#import "UnitTests/RunnerEdgeContactTesting.h"
#endif

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#import <Foundation/Foundation.h>
#import <CoreGraphics/CoreGraphics.h>
#import <TargetConditionals.h>

NS_ASSUME_NONNULL_BEGIN

#if TARGET_OS_IOS && TARGET_OS_SIMULATOR

// Dispatches one touch that UIKit marks as a screen-edge touch, so
// UIScreenEdgePanGestureRecognizer and the navigation back gesture can begin.
// XCTest synthesized touches never carry the digitizer edge mask that UIKit reads.
@interface RunnerSimulatorEdgeTouch : NSObject

// The touch goes to the touchscreen of the display that hosts the app: `resolvedWindow`'s display,
// or the sole screen when it is nil, as for XCTest synthesis. `samples` is the ordered
// { x, y, offsetMs } path of one contact in portrait screen points, and `screenSize` is that
// screen's portrait size in points. The call returns after the contact lifts.
// Returns nil on success, or the reason the touch could not be dispatched.
+ (NSString * _Nullable)dispatchWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
samples:(NSArray<NSDictionary<NSString *, NSNumber *> *> *)samples
screenSize:(CGSize)screenSize;

@end

#endif

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

#if TARGET_OS_IOS && TARGET_OS_SIMULATOR

#import "RunnerSimulatorEdgeContact.h"
#import "RunnerXCTestEventBridge.h"
#import <dlfcn.h>
#import <objc/message.h>

static NSString * _Nullable RunnerResolveHIDFunctions(RunnerHIDFunctions *functions) {
void *iokit = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW);
if (iokit == NULL) {
return @"simulator HID dispatch is unavailable: IOKit could not be loaded";
}
struct { void **slot; const char *name; } symbols[] = {
{ (void **)&functions->createDigitizerEvent, "IOHIDEventCreateDigitizerEvent" },
{ (void **)&functions->createFingerEvent, "IOHIDEventCreateDigitizerFingerEvent" },
{ (void **)&functions->appendEvent, "IOHIDEventAppendEvent" },
{ (void **)&functions->setIntegerValue, "IOHIDEventSetIntegerValue" },
{ (void **)&functions->setFloatValue, "IOHIDEventSetFloatValue" },
{ (void **)&functions->setSenderID, "IOHIDEventSetSenderID" },
{ (void **)&functions->setTimeStamp, "IOHIDEventSetTimeStamp" },
{ (void **)&functions->createClient, "IOHIDEventSystemClientCreate" },
{ (void **)&functions->copyServices, "IOHIDEventSystemClientCopyServices" },
{ (void **)&functions->copyServiceProperty, "IOHIDServiceClientCopyProperty" },
{ (void **)&functions->getServiceRegistryID, "IOHIDServiceClientGetRegistryID" },
{ (void **)&functions->dispatchEvent, "IOHIDEventSystemClientDispatchEvent" },
};
for (size_t index = 0; index < sizeof(symbols) / sizeof(symbols[0]); index++) {
*symbols[index].slot = dlsym(iokit, symbols[index].name);
if (*symbols[index].slot == NULL) {
return [NSString stringWithFormat:@"simulator HID dispatch is unavailable: %s is missing",
symbols[index].name];
}
}
return nil;
}

// The display UUID that a touchscreen HID service names for `displayID`, the XCTest display ID.
// A simulator runtime that gives its displays no UUID answers nil, and so do its touchscreens.
static NSString * _Nullable RunnerDisplayUUID(NSUInteger displayID) {
Class displayClass = NSClassFromString(@"CADisplay");
SEL displaysSelector = NSSelectorFromString(@"displays");
SEL displayIdSelector = NSSelectorFromString(@"displayId");
SEL uniqueIdSelector = NSSelectorFromString(@"uniqueId");
if (displayClass == Nil || ![displayClass respondsToSelector:displaysSelector]) return nil;
NSArray *displays = ((id (*)(id, SEL))objc_msgSend)(displayClass, displaysSelector);
for (id display in displays) {
if (![display respondsToSelector:displayIdSelector] || ![display respondsToSelector:uniqueIdSelector]) continue;
if (((unsigned int (*)(id, SEL))objc_msgSend)(display, displayIdSelector) != displayID) continue;
id uniqueId = ((id (*)(id, SEL))objc_msgSend)(display, uniqueIdSelector);
return [uniqueId isKindOfClass:[NSString class]] ? uniqueId : nil;
}
return nil;
}

static NSString * _Nullable RunnerResolveTouchscreenSender(
const RunnerHIDFunctions *functions,
CFTypeRef client,
NSUInteger displayID,
uint64_t *sender
) {
NSString *selection = nil;
NSString *failure = RunnerSelectTouchscreenSender(
functions, client, RunnerDisplayUUID(displayID), displayID, sender, &selection);
if (failure == nil) {
NSLog(@"AGENT_DEVICE_RUNNER_EDGE_TOUCHSCREEN display=%lu %@", (unsigned long)displayID, selection);
}
return failure;
}

@implementation RunnerSimulatorEdgeTouch

+ (NSString * _Nullable)dispatchWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
samples:(NSArray<NSDictionary<NSString *, NSNumber *> *> *)samples
screenSize:(CGSize)screenSize {
if (samples.count < 2 || screenSize.width <= 0 || screenSize.height <= 0) {
return @"an edge touch needs at least two samples and a measured screen";
}
NSUInteger displayID = 0;
id soleScreen = nil;
NSString *displayError = resolvedWindow == nil
? RunnerResolveSoleScreen(application, &soleScreen, &displayID)
: RunnerResolveWindowDisplayID(resolvedWindow, &displayID);
if (displayError != nil) return displayError;
RunnerHIDFunctions functions = {0};
NSString *failure = RunnerResolveHIDFunctions(&functions);
if (failure != nil) return failure;
CFTypeRef client = functions.createClient(kCFAllocatorDefault);
if (client == NULL) return @"simulator HID dispatch is unavailable: no event system client";
uint64_t sender = 0;
failure = RunnerResolveTouchscreenSender(&functions, client, displayID, &sender);
if (failure != nil) {
CFRelease(client);
return failure;
}

failure = RunnerDispatchEdgeContact(&functions, client, sender, samples, screenSize);
CFRelease(client);
return failure;
}

@end

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -661,12 +661,14 @@ extension RunnerTests {
error: ErrorPayload(code: "INVALID_ARGS", message: validationError)
)
}
switch plannedGestureExecution(for: plan) {
let execution = plannedGestureExecution(for: plan)
let response: Response
switch execution {
case .fastSwipe:
// Validation above guarantees a non-empty, single-pointer path for this execution kind.
let first = plan.pointers[0].samples.first!.point
let last = plan.pointers[0].samples.last!.point
return canonicalPlannedGestureResponse(
response = canonicalPlannedGestureResponse(
try executeDragGesture(
activeApp: activeApp,
x: first.x,
Expand All @@ -682,8 +684,16 @@ extension RunnerTests {
let (timing, outcome) = try performGesture(activeApp, idleTimeout: false) {
try sampledPlannedGesture(app: activeApp, plan: plan)
}
return plannedGestureResponse(plan: plan, timing: timing, outcome: outcome)
response = plannedGestureResponse(plan: plan, timing: timing, outcome: outcome)
#if os(iOS) && targetEnvironment(simulator)
case .screenEdgeTouch:
let (timing, outcome) = try performGesture(activeApp, idleTimeout: false) {
try screenEdgePlannedGesture(app: activeApp, plan: plan)
}
response = plannedGestureResponse(plan: plan, timing: timing, outcome: outcome)
#endif
}
return Self.reportingSchedule(execution.schedule, in: response)
case .gestureViewport:
let frame = try gestureViewportFrame(app: activeApp)
guard !frame.isNull, !frame.isInfinite, !frame.isEmpty else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ extension RunnerTests {
)
}

/// A successful planned gesture reports the motion schedule its route ran.
static func reportingSchedule(_ schedule: String?, in response: Response) -> Response {
guard response.ok, var data = response.data, let schedule else { return response }
data.executionProfile = schedule
return Response(ok: true, data: data)
}

func plannedGestureResponse(
plan: RunnerGesturePlan,
timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,29 @@ private struct RunnerUnsupportedOperationError: LocalizedError {
}

extension RunnerTests {
/// How a planned gesture reaches the device, with the one-pointer motion schedule that route runs.
enum PlannedGestureExecution: Equatable {
case fastSwipe
case sampled
/// `schedule` is the plan's profile for one pointer, and nil for several contacts.
case sampled(schedule: String?)
#if os(iOS) && targetEnvironment(simulator)
/// One HID touch marked as a screen-edge swipe, which only a simulator can dispatch.
case screenEdgeTouch
#endif

/// The schedule the response reports as `executionProfile`; ADR 0013 names each one.
var schedule: String? {
switch self {
case .fastSwipe:
return "endpoint-hold"
case .sampled(let schedule):
return schedule
#if os(iOS) && targetEnvironment(simulator)
case .screenEdgeTouch:
return "edge-ease-in"
#endif
}
}
}

enum SynthesizedDragProfile: Equatable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ struct RunnerGesturePlan: Codable {
let topology: String
let intent: String
let executionProfile: String?
/// `runnerScreenEdgeContactOrigin` when the contact starts as a screen-edge touch.
let contactOrigin: String?
let durationMs: Double
let viewport: RunnerGestureViewport
let pointers: [RunnerGesturePointer]
Expand Down Expand Up @@ -515,6 +517,8 @@ struct DataPayload: Codable {
var keyboardAvoided: Bool?
var keyboardMinY: Double?
var maestroNonHittableCoordinateFallbackUsed: Bool?
/// The one-pointer motion schedule a planned gesture ran.
var executionProfile: String?
var textEntryRoute: String?
/// A replacement whose field changed but whose value cannot echo the typed text: "unconfirmed",
/// with the TS `FillUnconfirmedVerification` evidence in `requested`, `before`, `after`, `target`.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import XCTest

/// The plan value for a contact that starts as a screen-edge touch. Mirrors
/// `GestureContactOrigin` in packages/contracts/src/gesture-plan-types.ts.
let runnerScreenEdgeContactOrigin = "screen-edge"

extension RunnerTests {
#if os(iOS) && targetEnvironment(simulator)
/// Dispatches a screen-edge plan as one HID touch with the digitizer edge mask. XCTest
/// synthesized touches never carry that mask, so UIKit edge recognizers ignore them.
@MainActor
func screenEdgePlannedGesture(
app: XCUIApplication,
plan: RunnerGesturePlan
) throws -> RunnerInteractionOutcome {
// The coordinate context books a foldable's lit-panel window and keeps its typed refusal.
let planned: NativePlannedGesture
switch try nativePlannedGesture(app: app, plan: plan, gesture: "edge swipe") {
case .resolved(let resolved): planned = resolved
case .refused(let outcome): return outcome
}
guard let portraitSize = edgeTouchDisplayPortraitSize(), let samples = planned.pointerSamples.first else {
return Self.unmeasuredSoleDisplayOutcome(gesture: "edge swipe")
}
if let message = RunnerSimulatorEdgeTouch.dispatch(
withApplication: app,
resolvedWindow: planned.context.resolvedWindow,
samples: samples,
screenSize: portraitSize
) {
return .unsupported(
message: message,
hint: "Retry after the app is foregrounded on a simulator display with a touchscreen."
)
}
return .performed
}

/// The portrait size of the display that hosts the app: the request's lit panel on a foldable,
/// whose sides swap with the window's turn, else the sole screen.
@MainActor
private func edgeTouchDisplayPortraitSize() -> CGSize? {
guard let panel = requestLitPanel else { return soleDisplayPortraitSize() }
return CGSize(width: min(panel.widthPt, panel.heightPt), height: max(panel.widthPt, panel.heightPt))
}
#endif
}
Loading
Loading