From c2f2acebf6908480f458fdf624372d3cc1a77f78 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 20 May 2026 07:37:31 -0700 Subject: [PATCH] feat(ios): hoist DebugBridgeTouch into canonical templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridges.swift.template imports DebugBridgeTouch but no .m/.h template shipped — consuming apps installing the canonical drop-in would hit a linker error. Closes that gap with the fixture's verified working code. Changes: - New ios-qa/templates/DebugBridgeTouch.{h,m}.template files (carbon copies of the fixture sources, including the iOS-18+ SwiftUI hit-test fix verified on iPhone 17 Pro Max). - Package.swift.template splits into 3 product targets: DebugBridgeCore (Swift, cross-platform), DebugBridgeUI (Swift, iOS-only), DebugBridgeTouch (Obj-C, iOS-only). Consuming app adds one dependency on DebugBridgeUI; Core + Touch come in transitively. - DebugBridgeTouch sources wrap their body in #if TARGET_OS_IOS so the cross-platform `swift build` on macOS host doesn't choke on UIKit. On iOS the real implementation is active; on macOS sendTapAtPoint: is a no-op returning NO. - New parity tests pin template ↔ fixture content so future fixture fixes propagate or fail loudly. - Restrict swift-build host tests to DebugBridgeCore (the only target buildable on macOS) and bring up the previously broken XCTest run via --filter. Verified post-change: real iPhone 17 Pro Max, iOS 26.5, three /tap requests against the rebuilt app — counter went 0 → 3, SwiftUI Button onTap fires every time. Templates now sufficient to ship to any consuming iOS app. Co-Authored-By: Claude Opus 4.7 (1M context) --- ios-qa/SKILL.md | 25 +- ios-qa/SKILL.md.tmpl | 25 +- ios-qa/templates/DebugBridgeTouch.h.template | 34 ++ ios-qa/templates/DebugBridgeTouch.m.template | 301 ++++++++++++++++++ ios-qa/templates/Package.swift.template | 55 +++- .../DebugBridgeTouch/DebugBridgeTouch.m | 18 ++ .../include/DebugBridgeTouch.h | 13 + test/skill-e2e-ios-swift-build.test.ts | 62 +++- 8 files changed, 504 insertions(+), 29 deletions(-) create mode 100644 ios-qa/templates/DebugBridgeTouch.h.template create mode 100644 ios-qa/templates/DebugBridgeTouch.m.template diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md index a4216c605..52a7f34c0 100644 --- a/ios-qa/SKILL.md +++ b/ios-qa/SKILL.md @@ -842,10 +842,27 @@ fi ## Phase 2: Bootstrap the device bridge -1. Add the `DebugBridge` SPM target (Debug-config-only via - `.when(configuration: .debug)`). -2. Add `DebugBridgeManager.shared.start()` to the app's `@main` entry, gated - on `#if DEBUG`. +1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package + ships three Debug-config-only library products: + - `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols. + - `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch + synthesis with iOS 18+ `_UIHitTestContext` SwiftUI hit-testing. + - `DebugBridgeUI` (Swift, iOS-only) — Screenshot / Elements / Mutation + bridge implementations. + The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)` + (transitively pulls in Core + Touch). Release builds refuse to link these + targets. +2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: + ```swift + #if DEBUG + import DebugBridgeCore + StateServer.shared.start() + #if canImport(UIKit) + import DebugBridgeUI + DebugBridgeUIWiring.installAll() + #endif + #endif + ``` 3. Build + deploy to the device with `xcodebuild -scheme -destination 'platform=iOS,id=' build install`. 4. Launch via `devicectl device process launch --device --console `. diff --git a/ios-qa/SKILL.md.tmpl b/ios-qa/SKILL.md.tmpl index 2aacac168..717ece245 100644 --- a/ios-qa/SKILL.md.tmpl +++ b/ios-qa/SKILL.md.tmpl @@ -107,10 +107,27 @@ fi ## Phase 2: Bootstrap the device bridge -1. Add the `DebugBridge` SPM target (Debug-config-only via - `.when(configuration: .debug)`). -2. Add `DebugBridgeManager.shared.start()` to the app's `@main` entry, gated - on `#if DEBUG`. +1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package + ships three Debug-config-only library products: + - `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols. + - `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch + synthesis with iOS 18+ `_UIHitTestContext` SwiftUI hit-testing. + - `DebugBridgeUI` (Swift, iOS-only) — Screenshot / Elements / Mutation + bridge implementations. + The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)` + (transitively pulls in Core + Touch). Release builds refuse to link these + targets. +2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: + ```swift + #if DEBUG + import DebugBridgeCore + StateServer.shared.start() + #if canImport(UIKit) + import DebugBridgeUI + DebugBridgeUIWiring.installAll() + #endif + #endif + ``` 3. Build + deploy to the device with `xcodebuild -scheme -destination 'platform=iOS,id=' build install`. 4. Launch via `devicectl device process launch --device --console `. diff --git a/ios-qa/templates/DebugBridgeTouch.h.template b/ios-qa/templates/DebugBridgeTouch.h.template new file mode 100644 index 000000000..1f85c1211 --- /dev/null +++ b/ios-qa/templates/DebugBridgeTouch.h.template @@ -0,0 +1,34 @@ +// +// DebugBridgeTouch.h — public Objective-C interface for in-process touch +// synthesis. Implementation derived from KIF (https://github.com/kif-framework/KIF), +// MIT-licensed. The minimal subset needed to deliver a real UITouch to a +// point on the key window, including SwiftUI Buttons via iOS 18+ +// _UIHitTestContext. DEBUG-only — never link in Release. + +#import +#import +#import + +#if TARGET_OS_IOS +#import +#else +// macOS build: forward-declare UIWindow so the module compiles without UIKit. +// The host CI runs swift build on macOS to validate the cross-platform Swift +// surface; DebugBridgeTouch's implementation is a no-op there. On iOS the +// real UIWindow comes from UIKit and the implementation is active. +@class UIWindow; +#endif + +NS_ASSUME_NONNULL_BEGIN + +@interface DebugBridgeTouch : NSObject + +/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given +/// window-coordinate point. Returns YES if the touch was delivered (a hit +/// view was found and the event passed through UIApplication.sendEvent). +/// On non-iOS platforms returns NO unconditionally. ++ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios-qa/templates/DebugBridgeTouch.m.template b/ios-qa/templates/DebugBridgeTouch.m.template new file mode 100644 index 000000000..7f7b7d1a3 --- /dev/null +++ b/ios-qa/templates/DebugBridgeTouch.m.template @@ -0,0 +1,301 @@ +// +// DebugBridgeTouch.m — minimal port of KIF's in-process touch synthesis. +// Original code: https://github.com/kif-framework/KIF — MIT-licensed +// (Square, Inc. + KIF contributors). Adapted to a single-file, tap-only, +// iOS 18+ aware subset for the gstack/ios-qa DebugBridge. +// +// Uses these private UIKit selectors (DEBUG-only; never shipped to App Store): +// UITouch: _setLocationInWindow:resetPrevious:, _setIsFirstTouchForView:, +// setPhase:, setTimestamp:, setView:, setWindow:, setTapCount:, +// _setHidEvent: +// UIEvent: _clearTouches, _addTouch:forDelayedDelivery:, _setHIDEvent: +// UIApplication: _touchesEvent +// UIView: _hitTestWithContext: (iOS 18+ for SwiftUI hit-testing) +// NSObject: _UIHitTestContext contextWithPoint:radius: (iOS 18+) +// +// IOKit private symbols (linked dynamically via the IOKit framework on iOS): +// IOHIDEventCreateDigitizerEvent, IOHIDEventCreateDigitizerFingerEventWithQuality, +// IOHIDEventSetIntegerValue, IOHIDEventAppendEvent. + +#import "DebugBridgeTouch.h" +#import + +#if TARGET_OS_IOS + +#import +#import +#import +#import + +#pragma mark - IOHIDEvent (private symbols from IOKit) + +typedef struct __IOHIDEvent * IOHIDEventRef; + +#define IOHIDEventFieldBase(type) (type << 16) +#ifdef __LP64__ +typedef double IOHIDFloat; +#else +typedef float IOHIDFloat; +#endif +typedef UInt32 IOOptionBits; +typedef uint32_t IOHIDDigitizerTransducerType; +typedef uint32_t IOHIDEventField; + +enum { + kIOHIDDigitizerTransducerTypeStylus = 0, + kIOHIDDigitizerTransducerTypePuck, + kIOHIDDigitizerTransducerTypeFinger, + kIOHIDDigitizerTransducerTypeHand +}; + +enum { + kIOHIDEventTypeDigitizer = 11, +}; + +enum { + kIOHIDDigitizerEventRange = 0x00000001, + kIOHIDDigitizerEventTouch = 0x00000002, + kIOHIDDigitizerEventPosition = 0x00000004, +}; + +enum { + kIOHIDEventFieldDigitizerX = IOHIDEventFieldBase(kIOHIDEventTypeDigitizer), + kIOHIDEventFieldDigitizerY, + kIOHIDEventFieldDigitizerZ, + kIOHIDEventFieldDigitizerButtonMask, + kIOHIDEventFieldDigitizerType, + kIOHIDEventFieldDigitizerIndex, + kIOHIDEventFieldDigitizerIdentity, + kIOHIDEventFieldDigitizerEventMask, + kIOHIDEventFieldDigitizerRange, + kIOHIDEventFieldDigitizerTouch, + kIOHIDEventFieldDigitizerPressure, + kIOHIDEventFieldDigitizerAuxiliaryPressure, + kIOHIDEventFieldDigitizerTwist, + kIOHIDEventFieldDigitizerTiltX, + kIOHIDEventFieldDigitizerTiltY, + kIOHIDEventFieldDigitizerAltitude, + kIOHIDEventFieldDigitizerAzimuth, + kIOHIDEventFieldDigitizerQuality, + kIOHIDEventFieldDigitizerDensity, + kIOHIDEventFieldDigitizerIrregularity, + kIOHIDEventFieldDigitizerMajorRadius, + kIOHIDEventFieldDigitizerMinorRadius, + kIOHIDEventFieldDigitizerCollection, + kIOHIDEventFieldDigitizerCollectionChord, + kIOHIDEventFieldDigitizerChildEventMask, + kIOHIDEventFieldDigitizerIsDisplayIntegrated, +}; + +// IOKit is a PRIVATE framework on iOS — we can't link it via -framework. Load +// at runtime via dlopen/dlsym. This is the standard approach for KIF-style +// touch synthesis on iOS, including in DEBUG-only test harnesses. +#import + +typedef IOHIDEventRef (*IOHIDEventCreateDigitizerEventFn)(CFAllocatorRef, AbsoluteTime, + IOHIDDigitizerTransducerType, uint32_t, uint32_t, uint32_t, uint32_t, + IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, Boolean, Boolean, IOOptionBits); + +typedef IOHIDEventRef (*IOHIDEventCreateDigitizerFingerEventWithQualityFn)(CFAllocatorRef, + AbsoluteTime, uint32_t, uint32_t, uint32_t, IOHIDFloat, IOHIDFloat, IOHIDFloat, + IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, + IOHIDFloat, Boolean, Boolean, IOOptionBits); + +typedef void (*IOHIDEventSetIntegerValueFn)(IOHIDEventRef, IOHIDEventField, int); +typedef void (*IOHIDEventAppendEventFn)(IOHIDEventRef, IOHIDEventRef); + +static IOHIDEventCreateDigitizerEventFn _IOHIDEventCreateDigitizerEvent; +static IOHIDEventCreateDigitizerFingerEventWithQualityFn _IOHIDEventCreateDigitizerFingerEventWithQuality; +static IOHIDEventSetIntegerValueFn _IOHIDEventSetIntegerValue; +static IOHIDEventAppendEventFn _IOHIDEventAppendEvent; + +static BOOL _IOKitLoaded = NO; +static BOOL DBT_LoadIOKit(void) { + if (_IOKitLoaded) return YES; + void *handle = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW); + if (!handle) { + handle = dlopen("/System/Library/PrivateFrameworks/IOKit.framework/IOKit", RTLD_NOW); + } + if (!handle) return NO; + _IOHIDEventCreateDigitizerEvent = (IOHIDEventCreateDigitizerEventFn)dlsym(handle, "IOHIDEventCreateDigitizerEvent"); + _IOHIDEventCreateDigitizerFingerEventWithQuality = (IOHIDEventCreateDigitizerFingerEventWithQualityFn)dlsym(handle, "IOHIDEventCreateDigitizerFingerEventWithQuality"); + _IOHIDEventSetIntegerValue = (IOHIDEventSetIntegerValueFn)dlsym(handle, "IOHIDEventSetIntegerValue"); + _IOHIDEventAppendEvent = (IOHIDEventAppendEventFn)dlsym(handle, "IOHIDEventAppendEvent"); + _IOKitLoaded = (_IOHIDEventCreateDigitizerEvent && _IOHIDEventCreateDigitizerFingerEventWithQuality && + _IOHIDEventSetIntegerValue && _IOHIDEventAppendEvent); + return _IOKitLoaded; +} + +static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED; +static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) { + if (!DBT_LoadIOKit()) return NULL; + uint64_t abTime = mach_absolute_time(); + AbsoluteTime timeStamp; + timeStamp.hi = (UInt32)(abTime >> 32); + timeStamp.lo = (UInt32)(abTime); + + IOHIDEventRef handEvent = _IOHIDEventCreateDigitizerEvent(kCFAllocatorDefault, + timeStamp, kIOHIDDigitizerTransducerTypeHand, + 0, 0, kIOHIDDigitizerEventTouch, 0, + 0, 0, 0, 0, 0, + 0, true, 0); + _IOHIDEventSetIntegerValue(handEvent, kIOHIDEventFieldDigitizerIsDisplayIntegrated, 1); + + uint32_t eventMask = (touch.phase == UITouchPhaseMoved) + ? kIOHIDDigitizerEventPosition + : (kIOHIDDigitizerEventRange | kIOHIDDigitizerEventTouch); + uint32_t isTouching = (touch.phase == UITouchPhaseEnded) ? 0 : 1; + + CGPoint loc = [touch locationInView:touch.window]; + + IOHIDEventRef fingerEvent = _IOHIDEventCreateDigitizerFingerEventWithQuality(kCFAllocatorDefault, + timeStamp, 1, 2, eventMask, + (IOHIDFloat)loc.x, (IOHIDFloat)loc.y, 0.0, + 0, 0, 5.0, 5.0, 1.0, 1.0, 1.0, + (IOHIDFloat)isTouching, (IOHIDFloat)isTouching, 0); + _IOHIDEventSetIntegerValue(fingerEvent, kIOHIDEventFieldDigitizerIsDisplayIntegrated, 1); + + _IOHIDEventAppendEvent(handEvent, fingerEvent); + CFRelease(fingerEvent); + + return handEvent; +} + +#pragma mark - Private selectors + +@interface UITouch () +- (void)setWindow:(UIWindow *)window; +- (void)setView:(UIView *)view; +- (void)setTapCount:(NSUInteger)tapCount; +- (void)setTimestamp:(NSTimeInterval)timestamp; +- (void)setPhase:(UITouchPhase)touchPhase; +- (void)setGestureView:(UIView *)view; +- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious; +- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView; +- (void)_setHidEvent:(IOHIDEventRef)event; +@end + +@interface UIEvent (DBTPrivate) +- (void)_clearTouches; +- (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayed; +- (void)_setHIDEvent:(IOHIDEventRef)event; +- (void)_setTimestamp:(NSTimeInterval)timestamp; +@end + +@interface UIApplication (DBTPrivate) +- (UIEvent *)_touchesEvent; +@end + +@interface UIView (DBTPrivate) +- (id)_hitTestWithContext:(id)context; +@end + +#pragma mark - SwiftUI-aware hit test (iOS 18+) + +// Returns `id` because iOS 18's _hitTestWithContext: can return either a UIView +// OR a SwiftUI.UIKitGestureContainer (a plain UIResponder, NOT a UIView). +// The latter is the case for SwiftUI Buttons. KIF's observation: the returned +// responder is still compatible with UITouch.setView: even when it isn't a +// UIView — so we pass it through as-is. Filtering by isKindOfClass:UIView +// here would drop every SwiftUI Button tap silently. Mirrors KIF PR #1323. +static id DBT_HitTestView(UIWindow *window, CGPoint point) { + UIView *fallback = [window hitTest:point withEvent:nil]; + + if (@available(iOS 18.0, *)) { + Class ctxClass = NSClassFromString(@"_UIHitTestContext"); + SEL ctxSel = NSSelectorFromString(@"contextWithPoint:radius:"); + if (ctxClass && [ctxClass respondsToSelector:ctxSel] && + [UIView instancesRespondToSelector:@selector(_hitTestWithContext:)]) { + id (*sendCtx)(id, SEL, CGPoint, CGFloat) = + (id (*)(id, SEL, CGPoint, CGFloat))objc_msgSend; + id ctx = sendCtx(ctxClass, ctxSel, point, 0); + if (ctx) { + id found = nil; + UIView *current = fallback; + while (found == nil && current != nil) { + found = [current _hitTestWithContext:ctx]; + current = current.superview; + } + if (found) { + return found; + } + } + } + } + return fallback; +} + +#pragma mark - Public API + +@implementation DebugBridgeTouch + ++ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { + if (!window) return NO; + + id hit = DBT_HitTestView(window, point); + if (!hit) return NO; + + // Build a single synthetic UITouch via private setters. Order matters — + // setWindow: clears internal state and must come first. + UITouch *touch = [[UITouch alloc] init]; + [touch setWindow:window]; + [touch setTapCount:1]; + [touch _setLocationInWindow:point resetPrevious:YES]; + // setView: typed UIView * but accepts SwiftUI.UIKitGestureContainer + // (UIResponder) too — that's how SwiftUI Buttons get routed on iOS 18+. + [touch setView:(UIView *)hit]; + [touch setPhase:UITouchPhaseBegan]; + if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) { + [touch _setIsFirstTouchForView:YES]; + } + [touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]]; + if ([touch respondsToSelector:@selector(setGestureView:)] && + [hit isKindOfClass:[UIView class]]) { + [touch setGestureView:(UIView *)hit]; + } + + // Attach a real IOHIDEvent (required iOS 9+). + IOHIDEventRef hidEventBegan = DBT_IOHIDEventWithTouch(touch); + [touch _setHidEvent:hidEventBegan]; + + UIEvent *event = [[UIApplication sharedApplication] _touchesEvent]; + if (!event) { + CFRelease(hidEventBegan); + return NO; + } + [event _clearTouches]; + [event _setHIDEvent:hidEventBegan]; + [event _addTouch:touch forDelayedDelivery:NO]; + + [[UIApplication sharedApplication] sendEvent:event]; + CFRelease(hidEventBegan); + + // Ended phase + [touch setPhase:UITouchPhaseEnded]; + [touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]]; + IOHIDEventRef hidEventEnded = DBT_IOHIDEventWithTouch(touch); + [touch _setHidEvent:hidEventEnded]; + [event _clearTouches]; + [event _setHIDEvent:hidEventEnded]; + [event _addTouch:touch forDelayedDelivery:NO]; + [[UIApplication sharedApplication] sendEvent:event]; + CFRelease(hidEventEnded); + + return YES; +} + +@end + +#else // !TARGET_OS_IOS + +// macOS / Catalyst / other non-iOS host build: no-op stub so the module +// resolves cleanly without UIKit or IOKit. The Swift cross-platform tests +// don't exercise touch synthesis; that's iOS-only by definition. +@implementation DebugBridgeTouch ++ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { + (void)point; (void)window; + return NO; +} +@end + +#endif // TARGET_OS_IOS diff --git a/ios-qa/templates/Package.swift.template b/ios-qa/templates/Package.swift.template index feaf3ec72..88d6bd319 100644 --- a/ios-qa/templates/Package.swift.template +++ b/ios-qa/templates/Package.swift.template @@ -1,8 +1,19 @@ // AUTO-GENERATED from gstack/ios-qa/templates/Package.swift.template // -// Drop-in SPM package definition for the DebugBridge target. The structural -// Release-build guard is the `.when(configuration: .debug)` conditional on -// every target dependency. SwiftPM refuses to link DebugBridge in Release. +// Drop-in SPM package definition for the DebugBridge stack. Three targets: +// +// - DebugBridgeCore Swift, cross-platform (Foundation + Network). +// Hosts the StateServer + bridge protocols. +// - DebugBridgeTouch Objective-C, iOS-only. KIF-derived in-process touch +// synthesis (UITouch + IOHIDEvent + iOS 18 +// _UIHitTestContext for SwiftUI Buttons). +// - DebugBridgeUI Swift, iOS-only. ScreenshotBridge, ElementsBridge, +// MutationBridge implementations. Depends on the other +// two. +// +// The structural Release-build guard is the `.when(configuration: .debug)` +// conditional on every consuming target's dependency. SwiftPM refuses to link +// DebugBridge* in Release config. // // CI invariant: `swift build -c release` + `nm -j build/Release/ // | grep -q DebugBridge && exit 1`. @@ -14,25 +25,43 @@ let package = Package( name: "DebugBridge", platforms: [.iOS(.v16), .macOS(.v13)], products: [ - .library(name: "DebugBridge", targets: ["DebugBridge"]), + .library(name: "DebugBridgeCore", targets: ["DebugBridgeCore"]), + .library(name: "DebugBridgeUI", targets: ["DebugBridgeUI"]), + .library(name: "DebugBridgeTouch", targets: ["DebugBridgeTouch"]), ], targets: [ .target( - name: "DebugBridge", + name: "DebugBridgeCore", dependencies: [], - path: "Sources/DebugBridge", + path: "Sources/DebugBridgeCore", + swiftSettings: [ + .define("DEBUG", .when(configuration: .debug)), + ] + ), + .target( + name: "DebugBridgeTouch", + dependencies: [], + path: "Sources/DebugBridgeTouch", + publicHeadersPath: "include", + linkerSettings: [ + // IOKit is loaded dynamically via dlopen at runtime (it's a + // private framework on iOS and can't be linked statically). + // UIKit links normally. + .linkedFramework("UIKit", .when(platforms: [.iOS])), + ] + ), + .target( + name: "DebugBridgeUI", + dependencies: ["DebugBridgeCore", "DebugBridgeTouch"], + path: "Sources/DebugBridgeUI", swiftSettings: [ - // Belt and suspenders. The host app's `.when(configuration: .debug)` - // condition on the dependency means we never link in Release. This - // setting additionally fails the build with a clear error if a - // user somehow forces the build. .define("DEBUG", .when(configuration: .debug)), ] ), .testTarget( - name: "DebugBridgeTests", - dependencies: ["DebugBridge"], - path: "Tests/DebugBridgeTests" + name: "DebugBridgeCoreTests", + dependencies: ["DebugBridgeCore"], + path: "Tests/DebugBridgeCoreTests" ), ] ) diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m index b3f9c978c..7f7b7d1a3 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m @@ -18,6 +18,10 @@ // IOHIDEventSetIntegerValue, IOHIDEventAppendEvent. #import "DebugBridgeTouch.h" +#import + +#if TARGET_OS_IOS + #import #import #import @@ -281,3 +285,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { } @end + +#else // !TARGET_OS_IOS + +// macOS / Catalyst / other non-iOS host build: no-op stub so the module +// resolves cleanly without UIKit or IOKit. The Swift cross-platform tests +// don't exercise touch synthesis; that's iOS-only by definition. +@implementation DebugBridgeTouch ++ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { + (void)point; (void)window; + return NO; +} +@end + +#endif // TARGET_OS_IOS diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h index 365d8b4df..1f85c1211 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h @@ -5,7 +5,19 @@ // point on the key window, including SwiftUI Buttons via iOS 18+ // _UIHitTestContext. DEBUG-only — never link in Release. +#import +#import +#import + +#if TARGET_OS_IOS #import +#else +// macOS build: forward-declare UIWindow so the module compiles without UIKit. +// The host CI runs swift build on macOS to validate the cross-platform Swift +// surface; DebugBridgeTouch's implementation is a no-op there. On iOS the +// real UIWindow comes from UIKit and the implementation is active. +@class UIWindow; +#endif NS_ASSUME_NONNULL_BEGIN @@ -14,6 +26,7 @@ NS_ASSUME_NONNULL_BEGIN /// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given /// window-coordinate point. Returns YES if the touch was delivered (a hit /// view was found and the event passed through UIApplication.sendEvent). +/// On non-iOS platforms returns NO unconditionally. + (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window; @end diff --git a/test/skill-e2e-ios-swift-build.test.ts b/test/skill-e2e-ios-swift-build.test.ts index 631dfb4c6..8a8c3b92b 100644 --- a/test/skill-e2e-ios-swift-build.test.ts +++ b/test/skill-e2e-ios-swift-build.test.ts @@ -19,11 +19,47 @@ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; const ROOT = join(import.meta.dir, '..'); const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp'); +const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates'); + +// Parity: canonical Obj-C touch templates must match the fixture's working +// copy. The fixture is the only place the .m / .h are exercised end-to-end +// on a real device, so any divergence means consuming apps would ship a +// stale, untested version of the SwiftUI hit-test fix. +describe('template ↔ fixture parity', () => { + test('DebugBridgeTouch.h.template matches fixture include', () => { + const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'); + const fixture = readFileSync( + join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), + 'utf-8', + ); + expect(tmpl).toBe(fixture); + }); + + test('DebugBridgeTouch.m.template matches fixture .m', () => { + const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); + const fixture = readFileSync( + join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), + 'utf-8', + ); + expect(tmpl).toBe(fixture); + }); + + test('Package.swift.template declares all 3 DebugBridge targets', () => { + const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); + // Each target must be present as a library product AND a target definition. + for (const name of ['DebugBridgeCore', 'DebugBridgeUI', 'DebugBridgeTouch']) { + expect(tmpl).toContain(`name: "${name}"`); + } + // DebugBridgeUI must depend on the other two; that's how the consuming + // app gets the transitive set with one dependency entry. + expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/); + }); +}); function hasSwift(): boolean { const r = spawnSync('swift', ['--version'], { stdio: 'pipe' }); @@ -34,8 +70,13 @@ const swiftAvailable = hasSwift(); const describeIfSwift = swiftAvailable ? describe : describe.skip; describeIfSwift('swift build invariants', () => { - test('Debug-config build succeeds', () => { - const r = spawnSync('swift', ['build', '-c', 'debug'], { + // DebugBridgeUI + DebugBridgeTouch are iOS-only (they link UIKit). Plain + // `swift build` on macOS host can't resolve UIKit, so we scope these + // invariants to DebugBridgeCore (Swift, cross-platform) + its XCTest + // target. The iOS-only targets are covered by xcodebuild on the device + // path (test/skill-e2e-ios-device.test.ts). + test('Debug-config build succeeds (DebugBridgeCore)', () => { + const r = spawnSync('swift', ['build', '-c', 'debug', '--target', 'DebugBridgeCore'], { cwd: FIXTURE_PATH, stdio: 'pipe', timeout: 120_000, @@ -47,7 +88,7 @@ describeIfSwift('swift build invariants', () => { }, 180_000); test('XCTest suite for StateServer passes (validates real Swift impl)', () => { - const r = spawnSync('swift', ['test'], { + const r = spawnSync('swift', ['test', '--filter', 'DebugBridgeCoreTests'], { cwd: FIXTURE_PATH, stdio: 'pipe', timeout: 180_000, @@ -59,18 +100,23 @@ describeIfSwift('swift build invariants', () => { console.error('swift test failure:', combined.slice(-4000)); } expect(r.status).toBe(0); - expect(combined).toContain("'All tests' passed"); + // --filter scopes the run to DebugBridgeCoreTests; the xctest summary + // line is "'Selected tests' passed" rather than "'All tests' passed". + expect(combined).toMatch(/'(?:All|Selected) tests' passed/); + // Guard against an empty pass-by-no-tests (filter typo / target rename): + // we expect at least one StateServer smoke test to actually execute. + expect(combined).toContain('StateServerSmokeTests'); }, 240_000); // Codex-flagged: Release-build guard must be STRUCTURAL, not advisory. // The Package.swift's `.when(configuration: .debug)` setting causes Swift - // to compile-out the entire DebugBridge target body in Release. Since + // to compile-out the entire DebugBridgeCore target body in Release. Since // every public symbol is gated `#if DEBUG`, the release build emits an // empty module — zero symbols. test('Release-config build excludes DebugBridge symbols', () => { - // Step 1: clean + release build + // Step 1: clean + release build (Core only — UI/Touch can't build on macOS) spawnSync('swift', ['package', 'clean'], { cwd: FIXTURE_PATH, stdio: 'pipe', timeout: 60_000 }); - const build = spawnSync('swift', ['build', '-c', 'release'], { + const build = spawnSync('swift', ['build', '-c', 'release', '--target', 'DebugBridgeCore'], { cwd: FIXTURE_PATH, stdio: 'pipe', timeout: 180_000,