Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1424962
refactor(ios): bind surface coordinates to their measured geometry
thymikee Nov 3, 2026
c5f8291
test(ios): preserve dark-window facts in the composed surface harness
thymikee Nov 3, 2026
b0a5db7
test(ios): keep bridge window facts independent of XCTest swizzles
thymikee Nov 3, 2026
152bb62
style(ios): align the composed surface test after rebase
thymikee Nov 3, 2026
afcb2d2
test(apple): box fixture rectangles on the host XCTest lane
thymikee Nov 3, 2026
bafe845
refactor(apple): make immutable surface geometry sendable
thymikee Nov 3, 2026
b0fdffd
refactor(ios): preserve typed booking refusals after the parent update
thymikee Nov 3, 2026
b9d69bf
refactor(ios): propagate prepared geometry refusals to sampled gestures
thymikee Nov 3, 2026
0cc584d
test(ios): require preparation before composed surface lookup
thymikee Nov 3, 2026
9809174
refactor(ios): preserve sole-screen admission in typed digitizer geom…
thymikee Nov 3, 2026
a7c547b
test(ios): carry typed geometry in the parent regression fixtures
thymikee Nov 3, 2026
d88b858
fix(ios): stop stale sequences and share synthesis orientation
thymikee Nov 3, 2026
2bdc046
fix(ios): keep gesture evidence in interface screen coordinates
thymikee Nov 3, 2026
f8eb169
style(ios): align the synthesis orientation interface
thymikee Nov 3, 2026
bf2566b
test(ios): keep placed-window query facts distinct from selector results
thymikee Nov 3, 2026
6fadb47
docs: record geometry review evidence and live limits
thymikee Nov 3, 2026
ec70e9e
refactor(ios): consolidate native drag dispatch and surface fixtures
thymikee Nov 3, 2026
5ded1a6
fix(apple): retain press series orientation across chunks
thymikee Nov 3, 2026
ef5326c
refactor(apple): isolate required series orientation check
thymikee Nov 3, 2026
eec9ac5
test(apple): drive bound sequences through the interactor
thymikee Nov 3, 2026
84f1e6a
test(apple): name sequence wire drive after interactions
thymikee Nov 3, 2026
2b9940a
docs: record the natural rejected-query route on a sole-display iPhone
thymikee Nov 4, 2026
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,4 +1,5 @@
import XCTest
import AgentDeviceSnapshotPresentation

// The lit panel of the command in flight (ADR 0025), main-owned and written behind main work a
// previous command left queued, so that work still reads its own command's panel.
Expand All @@ -11,7 +12,7 @@ struct LitPanelSurface {
let key: LitPanelBookingKey
let displayID: UInt
let window: XCUIElement
let frame: CGRect
let geometry: SurfaceGeometry
}

/// The command's lit panel and what it booked on it. Read and written only on the main thread.
Expand Down Expand Up @@ -79,7 +80,7 @@ extension RunnerTests {
guard failure == .none, let element = window as? XCUIElement else {
return RunnerAppScreenCaptureFailure(failure == .none ? .unresolvedWindow : failure)
}
litPanelState.surface = LitPanelSurface(key: key, displayID: litPanel.displayID, window: element, frame: frame)
litPanelState.surface = LitPanelSurface(key: key, displayID: litPanel.displayID, window: element, geometry: surfaceGeometry(windowFrame: frame, app: app))
return nil
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,7 @@ enum RunnerScrollKeyboardClip: Equatable {

/** Where one directional scroll may place its swipe, once the keyboard has taken its share. */
enum RunnerScrollViewport {
/**
* The band to plan the swipe inside, the frame to rotate its coordinates against, and the keyboard
* top when the band was clipped for one. The two frames are separate on purpose: a clip shortens
* only the band, while `CoordinateSpaceRotation.native(point:)` derives a `landscapeRight` native x from the
* frame's HEIGHT, so rotating inside the band moves the dispatched path sideways off the planned
* one.
*/
case swipe(planFrame: CGRect, coordinateFrame: CGRect, keyboardMinY: Double?)
case swipe(planFrame: CGRect, keyboardMinY: Double?)
/** Nothing to swipe. The caller answers `occlusionRunnerCode` and performs no gesture. */
case occluded(keyboardMinY: Double, visibleHeight: Double)
}
Expand All @@ -49,7 +42,6 @@ enum RunnerScrollViewport {
struct ScrollGestureDispatch {
let plan: RunnerScrollGesturePlan
let planFrame: CGRect
let coordinateFrame: CGRect
let keyboardMinY: Double?
}

Expand Down Expand Up @@ -91,7 +83,7 @@ extension RunnerScrollViewport {
switch self {
case .occluded(let keyboardMinY, let visibleHeight):
return .occluded(keyboardMinY: keyboardMinY, visibleHeight: visibleHeight)
case .swipe(let planFrame, let coordinateFrame, let keyboardMinY):
case .swipe(let planFrame, let keyboardMinY):
guard planFrame.width > 0, planFrame.height > 0 else {
return .unusableFrame
}
Expand All @@ -108,7 +100,6 @@ extension RunnerScrollViewport {
ScrollGestureDispatch(
plan: plan,
planFrame: planFrame,
coordinateFrame: coordinateFrame,
keyboardMinY: keyboardMinY
)
)
Expand Down Expand Up @@ -168,9 +159,9 @@ enum ScrollViewportPolicy {
static func frames(referenceFrame: CGRect, clip: RunnerScrollKeyboardClip) -> RunnerScrollViewport {
switch clip {
case .unobstructed:
return .swipe(planFrame: referenceFrame, coordinateFrame: referenceFrame, keyboardMinY: nil)
return .swipe(planFrame: referenceFrame, keyboardMinY: nil)
case .avoided(let frame, let keyboardMinY):
return .swipe(planFrame: frame, coordinateFrame: referenceFrame, keyboardMinY: keyboardMinY)
return .swipe(planFrame: frame, keyboardMinY: keyboardMinY)
case .occluded(let keyboardMinY, let visibleHeight):
return .occluded(keyboardMinY: keyboardMinY, visibleHeight: visibleHeight)
}
Expand All @@ -195,17 +186,17 @@ extension RunnerTests {
// Every scroll reports its decision, including the two ways it avoids reading the keyboard at
// all: unavailable accessibility health, and a probe that finds no keyboard.
guard context.allowsKeyboardProbe else {
logScrollViewport(decision: "probeSkipped", keyboardMinY: nil, swipeHeight: context.referenceFrame.height, context: context)
return ScrollViewportPolicy.frames(referenceFrame: context.referenceFrame, clip: .unobstructed)
logScrollViewport(decision: "probeSkipped", keyboardMinY: nil, swipeHeight: context.geometry.windowOnScreen.rect.height, context: context)
return ScrollViewportPolicy.frames(referenceFrame: context.geometry.windowOnScreen.rect, clip: .unobstructed)
}
guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
logScrollViewport(decision: "noKeyboard", keyboardMinY: nil, swipeHeight: context.referenceFrame.height, context: context)
return ScrollViewportPolicy.frames(referenceFrame: context.referenceFrame, clip: .unobstructed)
logScrollViewport(decision: "noKeyboard", keyboardMinY: nil, swipeHeight: context.geometry.windowOnScreen.rect.height, context: context)
return ScrollViewportPolicy.frames(referenceFrame: context.geometry.windowOnScreen.rect, clip: .unobstructed)
}
let clip = ScrollViewportPolicy.clip(viewport: context.referenceFrame, keyboard: keyboardFrame)
let clip = ScrollViewportPolicy.clip(viewport: context.geometry.windowOnScreen.rect, keyboard: keyboardFrame)
switch clip {
case .unobstructed:
logScrollViewport(decision: "unobstructed", keyboardMinY: nil, swipeHeight: context.referenceFrame.height, context: context)
logScrollViewport(decision: "unobstructed", keyboardMinY: nil, swipeHeight: context.geometry.windowOnScreen.rect.height, context: context)
case .avoided(let frame, let keyboardMinY):
logScrollViewport(
decision: "avoided",
Expand All @@ -221,7 +212,7 @@ extension RunnerTests {
context: context
)
}
return ScrollViewportPolicy.frames(referenceFrame: context.referenceFrame, clip: clip)
return ScrollViewportPolicy.frames(referenceFrame: context.geometry.windowOnScreen.rect, clip: clip)
#else
let fallbackFrame = try resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
return ScrollViewportPolicy.frames(referenceFrame: fallbackFrame, clip: .unobstructed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,36 @@ typedef NS_ENUM(NSInteger, RunnerTapSynthesisStatus) {
RunnerTapSynthesisStatusFailed,
};

typedef struct {
CGPoint start;
CGPoint end;
double durationMs;
BOOL controlledScroll;
} RunnerSynthesizedDrag;

@interface RunnerSynthesizedGesture : NSObject

// `resolvedWindow` is the app window the caller already resolved for the gesture's reference
// frame, so the record's display ID is read from that same window. Pass nil for the sole-screen
// route: the app's tree could not name its window and the screen is proven instead.
+ (NSString * _Nullable)synthesizeSwipeWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
x:(double)x
y:(double)y
x2:(double)x2
y2:(double)y2
durationMs:(double)durationMs;

+ (NSString * _Nullable)synthesizeControlledScrollWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
x:(double)x
y:(double)y
x2:(double)x2
y2:(double)y2
durationMs:(double)durationMs;
// `interfaceOrientation` is the orientation used to convert the supplied points.
+ (NSString * _Nullable)synthesizeDragWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
interfaceOrientation:(NSInteger)interfaceOrientation
drag:(RunnerSynthesizedDrag)drag;

+ (RunnerTapSynthesisStatus)synthesizeTapWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
x:(double)x
y:(double)y
interfaceOrientation:(NSInteger)interfaceOrientation
point:(CGPoint)point
deadline:(NSDate * _Nullable)deadline
errorMessage:(NSString * _Nullable * _Nullable)errorMessage;

// Each pointer is an ordered array of { x, y, offsetMs } samples. The first sample
// starts contact; subsequent samples move it; all pointers lift at their final offset.
+ (NSString * _Nullable)synthesizeGestureWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
interfaceOrientation:(NSInteger)interfaceOrientation
pointerSamples:(NSArray<NSArray<NSDictionary<NSString *, NSNumber *> *> *> *)pointerSamples;

// UIInterfaceOrientation of the app (1 portrait, 2 upsideDown, 3 landscapeRight,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
typedef struct {
RunnerXCTestEventBridge core;
SEL initRecordSelector;
SEL interfaceOrientationSelector;
SEL initPathSelector;
SEL moveSelector;
SEL liftSelector;
Expand All @@ -37,6 +36,7 @@ typedef id (*RunnerDragPointerPathFactory)(
static NSString * _Nullable RunnerCreateEventRecord(
id application,
id _Nullable resolvedWindow,
NSInteger interfaceOrientation,
NSString *recordName,
RunnerGestureEventBridge *bridge,
id *record
Expand All @@ -60,6 +60,7 @@ static id RunnerControlledScrollPointerPath(
static NSString * _Nullable RunnerTrySynthesizeDrag(
id application,
id _Nullable resolvedWindow,
NSInteger interfaceOrientation,
CGPoint start,
CGPoint end,
double durationMs,
Expand All @@ -69,6 +70,7 @@ static id RunnerControlledScrollPointerPath(
static RunnerTapSynthesisStatus RunnerTrySynthesizeTap(
id application,
id _Nullable resolvedWindow,
NSInteger interfaceOrientation,
CGPoint point,
NSDate * _Nullable deadline,
NSString * _Nullable * _Nullable errorMessage
Expand All @@ -85,44 +87,15 @@ static id RunnerTapPointerPath(

@implementation RunnerSynthesizedGesture

+ (NSString * _Nullable)synthesizeSwipeWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
x:(double)x
y:(double)y
x2:(double)x2
y2:(double)y2
durationMs:(double)durationMs {
@try {
return RunnerTrySynthesizeDrag(
application,
resolvedWindow,
CGPointMake(x, y),
CGPointMake(x2, y2),
durationMs,
@"agent-device-swipe",
RunnerSwipePointerPath
);
} @catch (NSException *exception) {
return RunnerFormatXCTestException(exception, @"private XCTest event synthesis failed");
}
}

+ (NSString * _Nullable)synthesizeControlledScrollWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
x:(double)x
y:(double)y
x2:(double)x2
y2:(double)y2
durationMs:(double)durationMs {
+ (NSString * _Nullable)synthesizeDragWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
interfaceOrientation:(NSInteger)interfaceOrientation
drag:(RunnerSynthesizedDrag)drag {
@try {
return RunnerTrySynthesizeDrag(
application,
resolvedWindow,
CGPointMake(x, y),
CGPointMake(x2, y2),
durationMs,
@"agent-device-controlled-scroll",
RunnerControlledScrollPointerPath
application, resolvedWindow, interfaceOrientation, drag.start, drag.end, drag.durationMs,
drag.controlledScroll ? @"agent-device-controlled-scroll" : @"agent-device-swipe",
drag.controlledScroll ? RunnerControlledScrollPointerPath : RunnerSwipePointerPath
);
} @catch (NSException *exception) {
return RunnerFormatXCTestException(exception, @"private XCTest event synthesis failed");
Expand All @@ -131,13 +104,13 @@ + (NSString * _Nullable)synthesizeControlledScrollWithApplication:(id)applicatio

+ (RunnerTapSynthesisStatus)synthesizeTapWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
x:(double)x
y:(double)y
interfaceOrientation:(NSInteger)interfaceOrientation
point:(CGPoint)point
deadline:(NSDate * _Nullable)deadline
errorMessage:(NSString * _Nullable * _Nullable)errorMessage {
if (errorMessage != NULL) *errorMessage = nil;
@try {
return RunnerTrySynthesizeTap(application, resolvedWindow, CGPointMake(x, y), deadline, errorMessage);
return RunnerTrySynthesizeTap(application, resolvedWindow, interfaceOrientation, point, deadline, errorMessage);
} @catch (NSException *exception) {
if (errorMessage != NULL) {
*errorMessage = RunnerFormatXCTestException(exception, @"private XCTest event synthesis failed");
Expand All @@ -148,13 +121,15 @@ + (RunnerTapSynthesisStatus)synthesizeTapWithApplication:(id)application

+ (NSString * _Nullable)synthesizeGestureWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
interfaceOrientation:(NSInteger)interfaceOrientation
pointerSamples:(NSArray<NSArray<NSDictionary<NSString *, NSNumber *> *> *> *)pointerSamples {
@try {
RunnerGestureEventBridge bridge;
id record = nil;
NSString *error = RunnerCreateEventRecord(
application,
resolvedWindow,
interfaceOrientation,
@"agent-device-gesture-plan",
&bridge,
&record
Expand Down Expand Up @@ -210,6 +185,7 @@ + (NSInteger)interfaceOrientationForApplication:(id)application {
static NSString * _Nullable RunnerTrySynthesizeDrag(
id application,
id _Nullable resolvedWindow,
NSInteger interfaceOrientation,
CGPoint start,
CGPoint end,
double durationMs,
Expand All @@ -218,7 +194,7 @@ + (NSInteger)interfaceOrientationForApplication:(id)application {
) {
RunnerGestureEventBridge bridge;
id record = nil;
NSString *error = RunnerCreateEventRecord(application, resolvedWindow, recordName, &bridge, &record);
NSString *error = RunnerCreateEventRecord(application, resolvedWindow, interfaceOrientation, recordName, &bridge, &record);
if (error != nil) return error;

id path = pathFactory(&bridge, start, end, durationMs);
Expand All @@ -233,6 +209,7 @@ + (NSInteger)interfaceOrientationForApplication:(id)application {
static RunnerTapSynthesisStatus RunnerTrySynthesizeTap(
id application,
id _Nullable resolvedWindow,
NSInteger interfaceOrientation,
CGPoint point,
NSDate * _Nullable deadline,
NSString * _Nullable * _Nullable errorMessage
Expand All @@ -242,6 +219,7 @@ static RunnerTapSynthesisStatus RunnerTrySynthesizeTap(
NSString *error = RunnerCreateEventRecord(
application,
resolvedWindow,
interfaceOrientation,
@"agent-device-tap",
&bridge,
&record
Expand Down Expand Up @@ -274,7 +252,6 @@ static RunnerTapSynthesisStatus RunnerTrySynthesizeTap(
if (missing != nil) return missing;

SEL initRecordSelector = NSSelectorFromString(@"initWithName:displayID:interfaceOrientation:");
SEL interfaceOrientationSelector = NSSelectorFromString(@"interfaceOrientation");
SEL initPathSelector = NSSelectorFromString(@"initForTouchAtPoint:offset:");
SEL moveSelector = NSSelectorFromString(@"moveToPoint:atOffset:");
SEL liftSelector = NSSelectorFromString(@"liftUpAtOffset:");
Expand All @@ -295,15 +272,10 @@ static RunnerTapSynthesisStatus RunnerTrySynthesizeTap(
core.pathClass, liftSelector, @"liftUpAtOffset:", RunnerGestureSynthesisSurface
);
if (missing != nil) return missing;
missing = RunnerRequireApplicationSelector(
application, interfaceOrientationSelector, @"interfaceOrientation", RunnerGestureSynthesisSurface
);
if (missing != nil) return missing;

*bridge = (RunnerGestureEventBridge){
.core = core,
.initRecordSelector = initRecordSelector,
.interfaceOrientationSelector = interfaceOrientationSelector,
.initPathSelector = initPathSelector,
.moveSelector = moveSelector,
.liftSelector = liftSelector,
Expand All @@ -314,6 +286,7 @@ static RunnerTapSynthesisStatus RunnerTrySynthesizeTap(
static NSString * _Nullable RunnerCreateEventRecord(
id application,
id _Nullable resolvedWindow,
NSInteger interfaceOrientation,
NSString *recordName,
RunnerGestureEventBridge *bridge,
id *record
Expand All @@ -329,8 +302,6 @@ static RunnerTapSynthesisStatus RunnerTrySynthesizeTap(
? RunnerResolveSoleScreen(application, &soleScreen, &displayID)
: RunnerResolveWindowDisplayID(resolvedWindow, &displayID);
if (displayError != nil) return displayError;
NSInteger interfaceOrientation =
((RunnerMsgSendInteger)objc_msgSend)(application, bridge->interfaceOrientationSelector);
NSInteger targetProcessID =
((RunnerMsgSendInteger)objc_msgSend)(application, bridge->core.processIDSelector);
if (targetProcessID <= 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ extension RunnerTests {
if let window = alert.window {
// Never fall back to the owner app's first window: on a foldable it can be another panel.
guard let context = SynthesizedCoordinateContext(
validating: safely("ALERT_WINDOW", CGRect.null) { window.frame },
validating: surfaceGeometry(windowFrame: safely("ALERT_WINDOW", CGRect.null) { window.frame }, app: alert.ownerApp),
resolvedWindow: window,
accessibilityHealth: mainOwned.accessibilityHealth
) else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ extension RunnerTests {
try 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) }
let touchFrame = context.map { touchVisualizationFrame(point: SurfacePoint(CGPoint(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)
Expand Down Expand Up @@ -338,7 +338,7 @@ extension RunnerTests {
y2: gesture.planFrame.minY + gesture.plan.y2,
durationMs: defaults.durationMs,
message: "scrolled",
context: scrollContext.withReferenceFrame(gesture.coordinateFrame),
context: scrollContext,
releaseBehavior: command.scrollReleaseBehavior
)
)
Expand Down Expand Up @@ -680,7 +680,7 @@ extension RunnerTests {
)
case .sampled:
let (timing, outcome) = try performGesture(activeApp, idleTimeout: false) {
sampledPlannedGesture(app: activeApp, plan: plan)
try sampledPlannedGesture(app: activeApp, plan: plan)
}
return plannedGestureResponse(plan: plan, timing: timing, outcome: outcome)
}
Expand Down
Loading
Loading