diff --git a/ios-qa/templates/Bridges.swift.template b/ios-qa/templates/Bridges.swift.template index bf7af6e3f..4a91142de 100644 --- a/ios-qa/templates/Bridges.swift.template +++ b/ios-qa/templates/Bridges.swift.template @@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring { /// times reinstalls the same closures. Must be called on @MainActor /// because every UIKit access requires the main actor. public static func installAll() { + // KIF turns on accessibility automation before walking SwiftUI's AX + // tree. Without it SwiftUI exposes only the hosting shell and taps + // can report success without invoking Button.action. + DebugBridgeTouch.prepareForAutomation() ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() } ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() } MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) } } } +/// Return the children UIKit exposes specifically to accessibility automation. +/// iOS 17 added `automationElements`; unlike the older container APIs it +/// preserves identified SwiftUI descendants inside accessibility groups. +@MainActor +private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] { + if #available(iOS 17.0, *), + let automation = element.automationElements, + !automation.isEmpty { + return automation.compactMap { $0 as? NSObject } + } + if let accessibility = element.accessibilityElements, + !accessibility.isEmpty { + return accessibility.compactMap { $0 as? NSObject } + } + let count = element.accessibilityElementCount() + guard count > 0, count < 512 else { return [] } + return (0.. Data? { guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil } let bounds = window.bounds - let renderer = UIGraphicsImageRenderer(bounds: bounds) + let format = UIGraphicsImageRendererFormat.default() + // /tap consumes UIKit window points. Render at 1x so screenshot pixels + // use that same coordinate space on 2x/3x devices. + format.scale = 1 + let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format) let image = renderer.image { _ in // drawHierarchy is the documented way to snapshot real UIKit // layers including layer-backed views. afterScreenUpdates: false @@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + let windows = scene.windows.filter { window in + !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow") + } + return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel }) } } @@ -76,21 +106,40 @@ enum ElementsBridgeImpl { static func snapshot() -> [JSONDict] { guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] } var elements: [JSONDict] = [] - collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements) + var visited = Set() + var remaining = 2_048 + collect( + view: window, + parentPath: "", + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) return elements } - private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) { + private static func collect( + view: UIView, + parentPath: String, + window: UIWindow, + visited: inout Set, + remaining: inout Int, + into elements: inout [JSONDict] + ) { + guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return } + remaining -= 1 + // Skip hidden / zero-size / off-screen subtrees early. if view.isHidden || view.alpha < 0.01 { return } - let frameInWindow = view.convert(view.bounds, to: nil) - if !windowBounds.intersects(frameInWindow) { return } + let frameInWindow = view.convert(view.bounds, to: window) + if !window.bounds.intersects(frameInWindow) { return } let isAccessible = view.isAccessibilityElement let label = view.accessibilityLabel ?? "" let identifier = view.accessibilityIdentifier ?? "" - let traits = Int(view.accessibilityTraits.rawValue) + let traits = NSNumber(value: view.accessibilityTraits.rawValue) let value = (view.accessibilityValue ?? "") as String let className = String(describing: type(of: view)) let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)" @@ -120,66 +169,98 @@ enum ElementsBridgeImpl { ]) } - // Recurse into accessibility-elements first (some custom views vend - // synthetic children), then UIView subviews. SwiftUI's host views - // populate accessibilityElements lazily — many return nil before - // VoiceOver triggers them. Force population by reading accessibilityElementCount. - _ = view.accessibilityElementCount() - if let axElements = view.accessibilityElements { - for case let element as NSObject in axElements { - if let v = element as? UIView { - collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements) - } else { - // Synthetic accessibility element (no UIView). Capture frame in screen coords. - let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero - elements.append([ - "path": "\(path) > ", - "class": "AccessibilityElement", - "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", - "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", - "value": (element.value(forKey: "accessibilityValue") as? String) ?? "", - "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, - "frame": [ - "x": Int(af.origin.x), - "y": Int(af.origin.y), - "w": Int(af.size.width), - "h": Int(af.size.height), - ], - "is_user_interaction_enabled": true, - ]) - } - } - } else { - // accessibilityElements is nil — iterate by index. SwiftUI uses - // this dynamic protocol pattern; many AX elements only respond - // to accessibilityElementCount + accessibilityElement(at:). - let count = view.accessibilityElementCount() - for i in 0.. ", - "class": String(describing: type(of: element)), - "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", - "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", - "value": (element.value(forKey: "accessibilityValue") as? String) ?? "", - "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, - "frame": [ - "x": Int(af.origin.x), - "y": Int(af.origin.y), - "w": Int(af.size.width), - "h": Int(af.size.height), - ], - "is_user_interaction_enabled": true, - ]) - } + // Walk automation children before raw subviews. On iOS 17+ this + // exposes identified SwiftUI controls nested inside GroupBox/List. + for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() { + if let child = element as? UIView { + collect( + view: child, + parentPath: path, + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) + } else { + appendSynthetic( + element, + path: "\(path) > ", + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) } } for sub in view.subviews { - collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements) + collect( + view: sub, + parentPath: path, + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) + } + } + + private static func appendSynthetic( + _ element: NSObject, + path: String, + window: UIWindow, + visited: inout Set, + remaining: inout Int, + into elements: inout [JSONDict] + ) { + guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return } + remaining -= 1 + + let screenFrame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero + let frame = window.coordinateSpace.convert(screenFrame, from: window.screen.coordinateSpace) + let label = (element.value(forKey: "accessibilityLabel") as? String) ?? "" + let identifier = (element.value(forKey: "accessibilityIdentifier") as? String) ?? "" + let value = (element.value(forKey: "accessibilityValue") as? String) ?? "" + let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0 + if !label.isEmpty || !identifier.isEmpty || !value.isEmpty || traits != 0 { + elements.append([ + "path": path, + "class": String(describing: type(of: element)), + "label": label, + "identifier": identifier, + "value": value, + "traits": NSNumber(value: traits), + "frame": [ + "x": Int(frame.origin.x), + "y": Int(frame.origin.y), + "w": Int(frame.size.width), + "h": Int(frame.size.height), + ], + "is_user_interaction_enabled": true, + ]) + } + + // Synthetic SwiftUI nodes are themselves accessibility containers. + // Recurse even when this grouping node has no metadata of its own. + for (index, child) in debugBridgeAccessibilityChildren(of: element).enumerated() { + if let childView = child as? UIView { + collect( + view: childView, + parentPath: path, + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) + } else { + appendSynthetic( + child, + path: "\(path) > ", + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) + } } } @@ -191,7 +272,10 @@ enum ElementsBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + let windows = scene.windows.filter { window in + !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow") + } + return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel }) } } @@ -210,21 +294,62 @@ enum MutationBridgeImpl { } } - /// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch - /// (KIF-derived in-process touch synthesis). The Obj-C target builds a - /// real UITouch + IOHIDEvent + UIEvent and dispatches via - /// `UIApplication.sendEvent`, which is what UIKit uses for real touches. - /// This works for UIControl, SwiftUI Button (via iOS 18+ - /// `_UIHitTestContext`), gesture recognizers, and anything else that - /// listens to the real event-dispatch path. + /// Tap at (x, y) in window coordinates. Prefer accessibility activation, + /// which is stable for SwiftUI buttons across OS releases, then fall back + /// to KIF-derived UITouch synthesis for gesture-only/custom controls. private static func handleTap(_ payload: JSONDict) -> Bool { guard let x = payload["x"] as? NSNumber, let y = payload["y"] as? NSNumber else { return false } let point = CGPoint(x: x.doubleValue, y: y.doubleValue) guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } + if let element = findActivatableAXElement(at: point, in: window), + element.accessibilityActivate() { + return true + } return DebugBridgeTouch.sendTap(at: point, in: window) } + private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? { + let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace) + var best: NSObject? + var bestArea: CGFloat = .infinity + var visited = Set() + var remaining = 2_048 + + func consider(frame: CGRect, traits: UInt64, element: NSObject) { + guard frame.contains(screenPoint), + (traits & UIAccessibilityTraits.button.rawValue) != 0 else { return } + let area = frame.width * frame.height + if area > 0 && area < bestArea { + best = element + bestArea = area + } + } + + func visit(_ element: NSObject) { + guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return } + remaining -= 1 + + if let view = element as? UIView { + guard !view.isHidden, view.alpha >= 0.01, + view.convert(view.bounds, to: window).contains(point) else { return } + if view.isAccessibilityElement { + consider(frame: view.accessibilityFrame, traits: view.accessibilityTraits.rawValue, element: view) + } + for child in debugBridgeAccessibilityChildren(of: view) { visit(child) } + for child in view.subviews { visit(child) } + } else { + let frame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero + let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0 + consider(frame: frame, traits: traits, element: element) + for child in debugBridgeAccessibilityChildren(of: element) { visit(child) } + } + } + + visit(window) + return best + } + /// Set text on the first responder if it's a UITextField or UITextView. private static func handleType(_ payload: JSONDict) -> Bool { guard let text = payload["text"] as? String else { return false } @@ -266,7 +391,10 @@ enum MutationBridgeImpl { var off = scroll.contentOffset off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx)) off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy)) - scroll.setContentOffset(off, animated: true) + // Automation commands return synchronously; do not report + // success while the target is still moving underneath the + // next tap coordinate. + scroll.setContentOffset(off, animated: false) return true } node = cur.superview @@ -301,7 +429,10 @@ enum MutationBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + let windows = scene.windows.filter { window in + !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow") + } + return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel }) } } diff --git a/ios-qa/templates/DebugBridgeTouch.h.template b/ios-qa/templates/DebugBridgeTouch.h.template index 1f85c1211..86a2ddaa8 100644 --- a/ios-qa/templates/DebugBridgeTouch.h.template +++ b/ios-qa/templates/DebugBridgeTouch.h.template @@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN @interface DebugBridgeTouch : NSObject +/// Enable the in-process accessibility automation tree used by SwiftUI and +/// UIKit. Call once while installing the debug bridge, before /elements. ++ (void)prepareForAutomation; + /// 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). diff --git a/ios-qa/templates/DebugBridgeTouch.m.template b/ios-qa/templates/DebugBridgeTouch.m.template index 7f7b7d1a3..aa6954c6d 100644 --- a/ios-qa/templates/DebugBridgeTouch.m.template +++ b/ios-qa/templates/DebugBridgeTouch.m.template @@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) { return _IOKitLoaded; } +// KIF enables accessibility automation before it asks UIKit/SwiftUI for the +// accessibility tree. Without this switch SwiftUI exposes only its hosting +// shell: /elements has no controls and a synthesized tap can return YES while +// Button.action never fires. Keep this DEBUG-only with the rest of this file. +typedef int (*DBTAXSAutomationEnabledFn)(void); +typedef void (*DBTAXSSetAutomationEnabledFn)(int); +static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled; +static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled; +static int _DBTInitialAutomationState = -1; + +static void DBT_RestoreAccessibilityAutomation(void) { + if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) { + _DBTAXSSetAutomationEnabled(_DBTInitialAutomationState); + } +} + +static void DBT_EnableAccessibilityAutomation(void) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL); + if (!handle) return; + _DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled"); + _DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled"); + if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return; + _DBTInitialAutomationState = _DBTAXSAutomationEnabled(); + if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1); + atexit(DBT_RestoreAccessibilityAutomation); + }); +} + static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED; static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) { if (!DBT_LoadIOKit()) return NULL; @@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) { - (void)setGestureView:(UIView *)view; - (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious; - (void)_setIsFirstTouchForView:(BOOL)firstTouchForView; +- (void)_setIsTapToClick:(BOOL)tapToClick; - (void)_setHidEvent:(IOHIDEventRef)event; @end @@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { @implementation DebugBridgeTouch ++ (void)prepareForAutomation { + DBT_EnableAccessibilityAutomation(); +} + + (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { if (!window) return NO; @@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { [touch setPhase:UITouchPhaseBegan]; if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) { [touch _setIsFirstTouchForView:YES]; + } else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) { + [touch _setIsTapToClick:YES]; + Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags"); + if (flagsIvar) { + ptrdiff_t offset = ivar_getOffset(flagsIvar); + char *flags = (__bridge void *)touch + offset; + *flags |= (char)0x01; + } } [touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]]; - if ([touch respondsToSelector:@selector(setGestureView:)] && - [hit isKindOfClass:[UIView class]]) { + if ([touch respondsToSelector:@selector(setGestureView:)]) { [touch setGestureView:(UIView *)hit]; } diff --git a/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json b/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json new file mode 100644 index 000000000..dd6ec5a68 --- /dev/null +++ b/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json @@ -0,0 +1,6 @@ +{ + "_schema_version": 1, + "_app_build_id": "uninitialized", + "_accessor_hash": "uninitialized", + "keys": {} +} diff --git a/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png b/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png new file mode 100644 index 000000000..c5f22e90e Binary files /dev/null and b/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png differ diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m index 7f7b7d1a3..aa6954c6d 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m @@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) { return _IOKitLoaded; } +// KIF enables accessibility automation before it asks UIKit/SwiftUI for the +// accessibility tree. Without this switch SwiftUI exposes only its hosting +// shell: /elements has no controls and a synthesized tap can return YES while +// Button.action never fires. Keep this DEBUG-only with the rest of this file. +typedef int (*DBTAXSAutomationEnabledFn)(void); +typedef void (*DBTAXSSetAutomationEnabledFn)(int); +static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled; +static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled; +static int _DBTInitialAutomationState = -1; + +static void DBT_RestoreAccessibilityAutomation(void) { + if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) { + _DBTAXSSetAutomationEnabled(_DBTInitialAutomationState); + } +} + +static void DBT_EnableAccessibilityAutomation(void) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL); + if (!handle) return; + _DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled"); + _DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled"); + if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return; + _DBTInitialAutomationState = _DBTAXSAutomationEnabled(); + if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1); + atexit(DBT_RestoreAccessibilityAutomation); + }); +} + static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED; static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) { if (!DBT_LoadIOKit()) return NULL; @@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) { - (void)setGestureView:(UIView *)view; - (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious; - (void)_setIsFirstTouchForView:(BOOL)firstTouchForView; +- (void)_setIsTapToClick:(BOOL)tapToClick; - (void)_setHidEvent:(IOHIDEventRef)event; @end @@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { @implementation DebugBridgeTouch ++ (void)prepareForAutomation { + DBT_EnableAccessibilityAutomation(); +} + + (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { if (!window) return NO; @@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { [touch setPhase:UITouchPhaseBegan]; if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) { [touch _setIsFirstTouchForView:YES]; + } else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) { + [touch _setIsTapToClick:YES]; + Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags"); + if (flagsIvar) { + ptrdiff_t offset = ivar_getOffset(flagsIvar); + char *flags = (__bridge void *)touch + offset; + *flags |= (char)0x01; + } } [touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]]; - if ([touch respondsToSelector:@selector(setGestureView:)] && - [hit isKindOfClass:[UIView class]]) { + if ([touch respondsToSelector:@selector(setGestureView:)]) { [touch setGestureView:(UIView *)hit]; } 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 1f85c1211..86a2ddaa8 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h @@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN @interface DebugBridgeTouch : NSObject +/// Enable the in-process accessibility automation tree used by SwiftUI and +/// UIKit. Call once while installing the debug bridge, before /elements. ++ (void)prepareForAutomation; + /// 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). diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift index bf7af6e3f..4a91142de 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift @@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring { /// times reinstalls the same closures. Must be called on @MainActor /// because every UIKit access requires the main actor. public static func installAll() { + // KIF turns on accessibility automation before walking SwiftUI's AX + // tree. Without it SwiftUI exposes only the hosting shell and taps + // can report success without invoking Button.action. + DebugBridgeTouch.prepareForAutomation() ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() } ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() } MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) } } } +/// Return the children UIKit exposes specifically to accessibility automation. +/// iOS 17 added `automationElements`; unlike the older container APIs it +/// preserves identified SwiftUI descendants inside accessibility groups. +@MainActor +private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] { + if #available(iOS 17.0, *), + let automation = element.automationElements, + !automation.isEmpty { + return automation.compactMap { $0 as? NSObject } + } + if let accessibility = element.accessibilityElements, + !accessibility.isEmpty { + return accessibility.compactMap { $0 as? NSObject } + } + let count = element.accessibilityElementCount() + guard count > 0, count < 512 else { return [] } + return (0.. Data? { guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil } let bounds = window.bounds - let renderer = UIGraphicsImageRenderer(bounds: bounds) + let format = UIGraphicsImageRendererFormat.default() + // /tap consumes UIKit window points. Render at 1x so screenshot pixels + // use that same coordinate space on 2x/3x devices. + format.scale = 1 + let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format) let image = renderer.image { _ in // drawHierarchy is the documented way to snapshot real UIKit // layers including layer-backed views. afterScreenUpdates: false @@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + let windows = scene.windows.filter { window in + !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow") + } + return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel }) } } @@ -76,21 +106,40 @@ enum ElementsBridgeImpl { static func snapshot() -> [JSONDict] { guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] } var elements: [JSONDict] = [] - collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements) + var visited = Set() + var remaining = 2_048 + collect( + view: window, + parentPath: "", + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) return elements } - private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) { + private static func collect( + view: UIView, + parentPath: String, + window: UIWindow, + visited: inout Set, + remaining: inout Int, + into elements: inout [JSONDict] + ) { + guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return } + remaining -= 1 + // Skip hidden / zero-size / off-screen subtrees early. if view.isHidden || view.alpha < 0.01 { return } - let frameInWindow = view.convert(view.bounds, to: nil) - if !windowBounds.intersects(frameInWindow) { return } + let frameInWindow = view.convert(view.bounds, to: window) + if !window.bounds.intersects(frameInWindow) { return } let isAccessible = view.isAccessibilityElement let label = view.accessibilityLabel ?? "" let identifier = view.accessibilityIdentifier ?? "" - let traits = Int(view.accessibilityTraits.rawValue) + let traits = NSNumber(value: view.accessibilityTraits.rawValue) let value = (view.accessibilityValue ?? "") as String let className = String(describing: type(of: view)) let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)" @@ -120,66 +169,98 @@ enum ElementsBridgeImpl { ]) } - // Recurse into accessibility-elements first (some custom views vend - // synthetic children), then UIView subviews. SwiftUI's host views - // populate accessibilityElements lazily — many return nil before - // VoiceOver triggers them. Force population by reading accessibilityElementCount. - _ = view.accessibilityElementCount() - if let axElements = view.accessibilityElements { - for case let element as NSObject in axElements { - if let v = element as? UIView { - collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements) - } else { - // Synthetic accessibility element (no UIView). Capture frame in screen coords. - let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero - elements.append([ - "path": "\(path) > ", - "class": "AccessibilityElement", - "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", - "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", - "value": (element.value(forKey: "accessibilityValue") as? String) ?? "", - "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, - "frame": [ - "x": Int(af.origin.x), - "y": Int(af.origin.y), - "w": Int(af.size.width), - "h": Int(af.size.height), - ], - "is_user_interaction_enabled": true, - ]) - } - } - } else { - // accessibilityElements is nil — iterate by index. SwiftUI uses - // this dynamic protocol pattern; many AX elements only respond - // to accessibilityElementCount + accessibilityElement(at:). - let count = view.accessibilityElementCount() - for i in 0.. ", - "class": String(describing: type(of: element)), - "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", - "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", - "value": (element.value(forKey: "accessibilityValue") as? String) ?? "", - "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, - "frame": [ - "x": Int(af.origin.x), - "y": Int(af.origin.y), - "w": Int(af.size.width), - "h": Int(af.size.height), - ], - "is_user_interaction_enabled": true, - ]) - } + // Walk automation children before raw subviews. On iOS 17+ this + // exposes identified SwiftUI controls nested inside GroupBox/List. + for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() { + if let child = element as? UIView { + collect( + view: child, + parentPath: path, + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) + } else { + appendSynthetic( + element, + path: "\(path) > ", + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) } } for sub in view.subviews { - collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements) + collect( + view: sub, + parentPath: path, + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) + } + } + + private static func appendSynthetic( + _ element: NSObject, + path: String, + window: UIWindow, + visited: inout Set, + remaining: inout Int, + into elements: inout [JSONDict] + ) { + guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return } + remaining -= 1 + + let screenFrame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero + let frame = window.coordinateSpace.convert(screenFrame, from: window.screen.coordinateSpace) + let label = (element.value(forKey: "accessibilityLabel") as? String) ?? "" + let identifier = (element.value(forKey: "accessibilityIdentifier") as? String) ?? "" + let value = (element.value(forKey: "accessibilityValue") as? String) ?? "" + let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0 + if !label.isEmpty || !identifier.isEmpty || !value.isEmpty || traits != 0 { + elements.append([ + "path": path, + "class": String(describing: type(of: element)), + "label": label, + "identifier": identifier, + "value": value, + "traits": NSNumber(value: traits), + "frame": [ + "x": Int(frame.origin.x), + "y": Int(frame.origin.y), + "w": Int(frame.size.width), + "h": Int(frame.size.height), + ], + "is_user_interaction_enabled": true, + ]) + } + + // Synthetic SwiftUI nodes are themselves accessibility containers. + // Recurse even when this grouping node has no metadata of its own. + for (index, child) in debugBridgeAccessibilityChildren(of: element).enumerated() { + if let childView = child as? UIView { + collect( + view: childView, + parentPath: path, + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) + } else { + appendSynthetic( + child, + path: "\(path) > ", + window: window, + visited: &visited, + remaining: &remaining, + into: &elements + ) + } } } @@ -191,7 +272,10 @@ enum ElementsBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + let windows = scene.windows.filter { window in + !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow") + } + return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel }) } } @@ -210,21 +294,62 @@ enum MutationBridgeImpl { } } - /// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch - /// (KIF-derived in-process touch synthesis). The Obj-C target builds a - /// real UITouch + IOHIDEvent + UIEvent and dispatches via - /// `UIApplication.sendEvent`, which is what UIKit uses for real touches. - /// This works for UIControl, SwiftUI Button (via iOS 18+ - /// `_UIHitTestContext`), gesture recognizers, and anything else that - /// listens to the real event-dispatch path. + /// Tap at (x, y) in window coordinates. Prefer accessibility activation, + /// which is stable for SwiftUI buttons across OS releases, then fall back + /// to KIF-derived UITouch synthesis for gesture-only/custom controls. private static func handleTap(_ payload: JSONDict) -> Bool { guard let x = payload["x"] as? NSNumber, let y = payload["y"] as? NSNumber else { return false } let point = CGPoint(x: x.doubleValue, y: y.doubleValue) guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } + if let element = findActivatableAXElement(at: point, in: window), + element.accessibilityActivate() { + return true + } return DebugBridgeTouch.sendTap(at: point, in: window) } + private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? { + let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace) + var best: NSObject? + var bestArea: CGFloat = .infinity + var visited = Set() + var remaining = 2_048 + + func consider(frame: CGRect, traits: UInt64, element: NSObject) { + guard frame.contains(screenPoint), + (traits & UIAccessibilityTraits.button.rawValue) != 0 else { return } + let area = frame.width * frame.height + if area > 0 && area < bestArea { + best = element + bestArea = area + } + } + + func visit(_ element: NSObject) { + guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return } + remaining -= 1 + + if let view = element as? UIView { + guard !view.isHidden, view.alpha >= 0.01, + view.convert(view.bounds, to: window).contains(point) else { return } + if view.isAccessibilityElement { + consider(frame: view.accessibilityFrame, traits: view.accessibilityTraits.rawValue, element: view) + } + for child in debugBridgeAccessibilityChildren(of: view) { visit(child) } + for child in view.subviews { visit(child) } + } else { + let frame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero + let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0 + consider(frame: frame, traits: traits, element: element) + for child in debugBridgeAccessibilityChildren(of: element) { visit(child) } + } + } + + visit(window) + return best + } + /// Set text on the first responder if it's a UITextField or UITextView. private static func handleType(_ payload: JSONDict) -> Bool { guard let text = payload["text"] as? String else { return false } @@ -266,7 +391,10 @@ enum MutationBridgeImpl { var off = scroll.contentOffset off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx)) off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy)) - scroll.setContentOffset(off, animated: true) + // Automation commands return synchronously; do not report + // success while the target is still moving underneath the + // next tap coordinate. + scroll.setContentOffset(off, animated: false) return true } node = cur.superview @@ -301,7 +429,10 @@ enum MutationBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + let windows = scene.windows.filter { window in + !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow") + } + return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel }) } } diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift index af18b72e3..c96c92eaf 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift @@ -1,15 +1,21 @@ -// FixtureApp — minimal SwiftUI app used by the ios-qa device-path E2E test. +// FixtureApp — interaction-rich SwiftUI app used by the ios-qa device-path +// E2E test. Every control exposes a stable accessibility identifier and writes +// to visible verification state so device-driven taps have an explicit oracle. // // On launch: -// 1. Boot StateServer (loopback :::1/127.0.0.1 + 9999) -// 2. Log boot token to os_log so devicectl + the Mac daemon can scrape it -// 3. Render a single ContentView so the app stays foreground +// 1. Install UI resolvers before any request can arrive +// 2. Register typed state and boot StateServer (::1/127.0.0.1 + 9999) +// 3. Log the one-use boot token, then render the interaction harness // // Everything ios-qa-related is gated #if DEBUG. Release builds compile this // to a no-op app (no StateServer, no DebugBridge import, no overlay). import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + #if DEBUG import DebugBridgeCore #endif @@ -20,14 +26,21 @@ import DebugBridgeUI @main struct FixtureAppApp: App { + #if DEBUG + private let appState = FixtureAppState() + #endif + init() { #if DEBUG - StateServer.shared.start() // Wire the three UIKit-backed bridges so /screenshot, /elements, - // /tap, /type, /swipe actually do something on the device. + // /tap, /type, /swipe are ready before the listener accepts requests. #if canImport(UIKit) DebugBridgeUIWiring.installAll() #endif + DebugBridgeManager.shared.start( + appState: appState, + register: FixtureAppStateAccessor.register + ) #endif } @@ -39,22 +52,707 @@ struct FixtureAppApp: App { } struct ContentView: View { - @State private var counter: Int = 0 + private enum HarnessTab: String, Hashable { + case controls + case inputs + case rows + + var title: String { rawValue.capitalized } + } + + private struct HarnessRow: Identifiable { + let id: String + let title: String + let symbol: String + } + + private static let harnessRows = [ + HarnessRow(id: "alpha", title: "Alpha row", symbol: "a.circle.fill"), + HarnessRow(id: "bravo", title: "Bravo row", symbol: "b.circle.fill"), + HarnessRow(id: "charlie", title: "Charlie row", symbol: "c.circle.fill"), + HarnessRow(id: "delta", title: "Delta row", symbol: "d.circle.fill"), + ] + + @State private var selectedTab: HarnessTab = .controls + @State private var lastAction = "Harness ready" + @State private var totalActions = 0 + @State private var tabChangeCount = 0 + + @State private var primaryButtonCount = 0 + @State private var borderedButtonCount = 0 + @State private var plainButtonCount = 0 + @State private var destructiveButtonCount = 0 + @State private var toolbarRefreshCount = 0 + @State private var menuAddCount = 0 + @State private var menuArchiveCount = 0 + @State private var detailOpenCount = 0 + @State private var detailCloseCount = 0 + @State private var detailButtonCount = 0 + @State private var isShowingDetail = false + + @State private var toggleValue = false + @State private var toggleChangeCount = 0 + @State private var stepperValue = 0 + @State private var stepperChangeCount = 0 + @State private var selectedMode = "One" + @State private var pickerChangeCount = 0 + @State private var draftText = "" + @FocusState private var isTextFieldFocused: Bool + @State private var textChangeCount = 0 + @State private var textCommitCount = 0 + @State private var uikitButtonCount = 0 + + @State private var rowTapCounts: [String: Int] = [:] + @State private var rowFlagCount = 0 + @State private var rowArchiveCount = 0 + @State private var rowToolbarCount = 0 var body: some View { - VStack(spacing: 24) { - Text("ios-qa fixture") - .font(.largeTitle.bold()) - Text("StateServer should be on :9999") - .font(.subheadline) - .foregroundColor(.secondary) - Button("Tap (\(counter))") { - counter += 1 - } - .buttonStyle(.borderedProminent) - .accessibilityIdentifier("tap-button") + TabView(selection: $selectedTab) { + controlsTab + .tabItem { + Label("Controls", systemImage: "hand.tap.fill") + .accessibilityIdentifier("tab-controls") + } + .tag(HarnessTab.controls) + + inputsTab + .tabItem { + Label("Inputs", systemImage: "slider.horizontal.3") + .accessibilityIdentifier("tab-inputs") + } + .tag(HarnessTab.inputs) + + rowsTab + .tabItem { + Label("Rows", systemImage: "list.bullet.rectangle") + .accessibilityIdentifier("tab-rows") + } + .tag(HarnessTab.rows) } - .padding() - .accessibilityIdentifier("fixture-content") + .accessibilityIdentifier("fixture-tab-view") + .onChange(of: selectedTab) { newTab in + tabChangeCount += 1 + record("Selected \(newTab.title) tab") + } + } + + private var controlsTab: some View { + NavigationStack { + ScrollView { + VStack(spacing: 14) { + verificationPanel + + GroupBox { + LazyVGrid( + columns: [GridItem(.flexible()), GridItem(.flexible())], + spacing: 10 + ) { + Button { + primaryButtonCount += 1 + record("Primary button tapped") + } label: { + buttonLabel("Primary", count: primaryButtonCount, symbol: "bolt.fill") + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("primary-button") + .accessibilityValue("\(primaryButtonCount) taps") + + Button { + borderedButtonCount += 1 + record("Bordered button tapped") + } label: { + buttonLabel("Bordered", count: borderedButtonCount, symbol: "square") + } + .buttonStyle(.bordered) + .accessibilityIdentifier("bordered-button") + .accessibilityValue("\(borderedButtonCount) taps") + + Button { + plainButtonCount += 1 + record("Plain button tapped") + } label: { + buttonLabel("Plain", count: plainButtonCount, symbol: "circle") + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("plain-button") + .accessibilityValue("\(plainButtonCount) taps") + + Button(role: .destructive) { + destructiveButtonCount += 1 + record("Destructive-style button tapped") + } label: { + buttonLabel( + "Destructive", + count: destructiveButtonCount, + symbol: "exclamationmark.triangle.fill" + ) + } + .buttonStyle(.bordered) + .accessibilityIdentifier("destructive-button") + .accessibilityValue("\(destructiveButtonCount) taps") + } + } label: { + Label("SwiftUI button styles", systemImage: "rectangle.3.group") + .font(.headline) + } + .accessibilityIdentifier("button-styles-group") + + GroupBox { + VStack(spacing: 10) { + verificationRow( + "Refresh", + value: toolbarRefreshCount, + identifier: "nav-refresh-count" + ) + verificationRow( + "Menu add", + value: menuAddCount, + identifier: "menu-add-count" + ) + verificationRow( + "Menu archive", + value: menuArchiveCount, + identifier: "menu-archive-count" + ) + verificationRow( + "Detail opened / closed", + textValue: "\(detailOpenCount) / \(detailCloseCount)", + identifier: "detail-navigation-count" + ) + } + } label: { + Label("Navigation and menu results", systemImage: "menubar.rectangle") + .font(.headline) + } + .accessibilityIdentifier("navigation-results-group") + + Button { + detailOpenCount += 1 + isShowingDetail = true + } label: { + Label("Open detail screen (\(detailOpenCount))", systemImage: "chevron.forward.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.indigo) + .accessibilityIdentifier("open-detail-button") + .accessibilityValue("Opened \(detailOpenCount) times") + } + .padding() + } + .accessibilityIdentifier("controls-scroll-view") + .navigationTitle("Tap Lab") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + toolbarRefreshCount += 1 + record("Navigation refresh tapped") + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .accessibilityIdentifier("nav-refresh-button") + .accessibilityValue("\(toolbarRefreshCount) refreshes") + } + + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button { + menuAddCount += 1 + record("Add menu item selected") + } label: { + Label("Add item (\(menuAddCount))", systemImage: "plus") + } + .accessibilityIdentifier("menu-add-item") + + Button { + menuArchiveCount += 1 + record("Archive menu item selected") + } label: { + Label("Archive item (\(menuArchiveCount))", systemImage: "archivebox") + } + .accessibilityIdentifier("menu-archive-item") + } label: { + Image(systemName: "ellipsis.circle") + .accessibilityLabel("Harness actions menu") + } + .accessibilityIdentifier("toolbar-actions-menu") + .accessibilityValue("Add \(menuAddCount), archive \(menuArchiveCount)") + } + } + .navigationDestination(isPresented: $isShowingDetail) { + VStack(spacing: 18) { + verificationPanel + + Image(systemName: "rectangle.stack.badge.play.fill") + .font(.system(size: 46)) + .foregroundColor(.indigo) + .accessibilityIdentifier("detail-screen-artwork") + + Text("Navigation destination") + .font(.title2.bold()) + .accessibilityIdentifier("detail-screen-title") + + Button { + detailButtonCount += 1 + record("Detail button tapped") + } label: { + Label("Detail tap (\(detailButtonCount))", systemImage: "hand.tap") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("detail-action-button") + .accessibilityValue("\(detailButtonCount) taps") + + Text("Detail count: \(detailButtonCount)") + .font(.headline.monospacedDigit()) + .accessibilityIdentifier("detail-action-count") + } + .padding() + .navigationTitle("Detail") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + isShowingDetail = false + } label: { + Label("Back to Tap Lab", systemImage: "chevron.backward") + } + .accessibilityIdentifier("detail-back-button") + } + } + } + .onChange(of: isShowingDetail) { isShowing in + if isShowing { + record("Opened detail screen") + } else { + detailCloseCount += 1 + record("Closed detail screen") + } + } + } + .accessibilityIdentifier("controls-navigation-stack") + } + + private var inputsTab: some View { + NavigationStack { + ScrollView { + VStack(spacing: 14) { + verificationPanel + + GroupBox { + VStack(spacing: 14) { + Toggle(isOn: Binding( + get: { toggleValue }, + set: { newValue in + toggleValue = newValue + toggleChangeCount += 1 + record("Toggle changed to \(newValue ? "on" : "off")") + } + )) { + Label("Harness toggle", systemImage: toggleValue ? "checkmark.circle.fill" : "circle") + } + .accessibilityIdentifier("harness-toggle") + .accessibilityValue(toggleValue ? "On" : "Off") + + verificationRow( + "Toggle changes", + value: toggleChangeCount, + identifier: "toggle-change-count" + ) + + Divider() + + Stepper( + value: Binding( + get: { stepperValue }, + set: { newValue in + let direction = newValue > stepperValue ? "up" : "down" + stepperValue = newValue + stepperChangeCount += 1 + record("Stepper moved \(direction) to \(newValue)") + } + ), + in: 0...9 + ) { + Label("Stepper value: \(stepperValue)", systemImage: "plusminus.circle") + .monospacedDigit() + } + .accessibilityIdentifier("harness-stepper") + .accessibilityValue("Value \(stepperValue), changed \(stepperChangeCount) times") + + verificationRow( + "Stepper changes", + value: stepperChangeCount, + identifier: "stepper-change-count" + ) + verificationRow( + "Stepper value", + value: stepperValue, + identifier: "stepper-value" + ) + } + } label: { + Label("Toggle and stepper", systemImage: "switch.2") + .font(.headline) + } + .accessibilityIdentifier("toggle-stepper-group") + + GroupBox { + VStack(alignment: .leading, spacing: 12) { + Picker("Harness mode", selection: Binding( + get: { selectedMode }, + set: { newValue in + selectedMode = newValue + pickerChangeCount += 1 + record("Segment selected: \(newValue)") + } + )) { + Text("One").tag("One") + Text("Two").tag("Two") + Text("Three").tag("Three") + } + .pickerStyle(.segmented) + .accessibilityIdentifier("harness-segmented-picker") + .accessibilityValue("Selected \(selectedMode)") + + verificationRow( + "Selected segment", + textValue: selectedMode, + identifier: "picker-selection-value" + ) + verificationRow( + "Segment changes", + value: pickerChangeCount, + identifier: "picker-change-count" + ) + } + } label: { + Label("Segmented picker", systemImage: "rectangle.split.3x1") + .font(.headline) + } + .accessibilityIdentifier("picker-group") + + GroupBox { + VStack(alignment: .leading, spacing: 10) { + TextField("Type a device message", text: $draftText) + .focused($isTextFieldFocused) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.done) + .accessibilityIdentifier("harness-text-field") + .accessibilityValue(draftText) + .onSubmit { + commitText(source: "keyboard submit") + } + .onChange(of: draftText) { newValue in + textChangeCount += 1 + record("Text changed to \(newValue.isEmpty ? "empty" : newValue)") + } + + HStack { + Text("Echo: \(draftText.isEmpty ? "" : draftText)") + .font(.subheadline.monospaced()) + .lineLimit(1) + .accessibilityIdentifier("text-echo-value") + .accessibilityValue(draftText) + Spacer() + Button("Commit") { + commitText(source: "commit button") + } + .buttonStyle(.bordered) + .accessibilityIdentifier("commit-text-button") + .accessibilityValue("Committed \(textCommitCount) times") + } + + verificationRow( + "Text changes", + value: textChangeCount, + identifier: "text-change-count" + ) + verificationRow( + "Text commits", + value: textCommitCount, + identifier: "text-commit-count" + ) + } + } label: { + Label("Text entry", systemImage: "keyboard") + .font(.headline) + } + .accessibilityIdentifier("text-entry-group") + + #if canImport(UIKit) + GroupBox { + VStack(spacing: 10) { + UIKitHarnessButton(count: uikitButtonCount) { + uikitButtonCount += 1 + record("UIKit UIButton tapped") + } + .frame(maxWidth: .infinity, minHeight: 48) + + verificationRow( + "UIKit taps", + value: uikitButtonCount, + identifier: "uikit-button-count" + ) + } + } label: { + Label("Native UIKit control", systemImage: "iphone") + .font(.headline) + } + .accessibilityIdentifier("uikit-control-group") + #endif + } + .padding() + } + .accessibilityIdentifier("inputs-scroll-view") + .navigationTitle("Input Lab") + .navigationBarTitleDisplayMode(.inline) + } + .accessibilityIdentifier("inputs-navigation-stack") + } + + private var rowsTab: some View { + NavigationStack { + List { + Section { + verificationPanel + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + + Section("Tap a row") { + ForEach(Self.harnessRows) { row in + Button { + let count = rowTapCounts[row.id, default: 0] + 1 + rowTapCounts[row.id] = count + record("\(row.title) tapped") + } label: { + HStack(spacing: 12) { + Image(systemName: row.symbol) + .foregroundColor(.accentColor) + Text(row.title) + Spacer() + Text("\(rowTapCounts[row.id, default: 0])") + .font(.headline.monospacedDigit()) + .foregroundColor(.secondary) + .accessibilityIdentifier("row-\(row.id)-count") + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("row-\(row.id)-button") + .accessibilityValue("\(rowTapCounts[row.id, default: 0]) taps") + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + rowFlagCount += 1 + record("Flagged \(row.title)") + } label: { + Label("Flag", systemImage: "flag.fill") + } + .tint(.orange) + .accessibilityIdentifier("row-\(row.id)-flag-action") + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + rowArchiveCount += 1 + record("Archived \(row.title)") + } label: { + Label("Archive", systemImage: "archivebox.fill") + } + .tint(.indigo) + .accessibilityIdentifier("row-\(row.id)-archive-action") + } + } + } + + Section("Row action results") { + verificationRow( + "Flags", + value: rowFlagCount, + identifier: "row-flag-count" + ) + verificationRow( + "Archives", + value: rowArchiveCount, + identifier: "row-archive-count" + ) + verificationRow( + "Toolbar checks", + value: rowToolbarCount, + identifier: "row-toolbar-count" + ) + } + } + .listStyle(.insetGrouped) + .accessibilityIdentifier("rows-list") + .navigationTitle("Row Lab") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + rowToolbarCount += 1 + record("Rows toolbar check tapped") + } label: { + Label("Check rows", systemImage: "checkmark.circle") + } + .accessibilityIdentifier("rows-toolbar-button") + .accessibilityValue("\(rowToolbarCount) checks") + } + } + } + .accessibilityIdentifier("rows-navigation-stack") + } + + private var verificationPanel: some View { + VerificationPanel( + lastAction: lastAction, + totalActions: totalActions, + selectedTab: selectedTab.title, + tabChangeCount: tabChangeCount + ) + } + + private func buttonLabel(_ title: String, count: Int, symbol: String) -> some View { + VStack(spacing: 4) { + Label(title, systemImage: symbol) + .lineLimit(1) + Text("Count \(count)") + .font(.caption.monospacedDigit()) + .accessibilityIdentifier("\(title.lowercased())-button-count") + } + .frame(maxWidth: .infinity, minHeight: 40) + } + + private func verificationRow(_ label: String, value: Int, identifier: String) -> some View { + verificationRow(label, textValue: String(value), identifier: identifier) + } + + private func verificationRow(_ label: String, textValue: String, identifier: String) -> some View { + HStack { + Text(label) + .foregroundColor(.secondary) + Spacer() + Text(textValue) + .font(.subheadline.bold().monospacedDigit()) + .accessibilityIdentifier(identifier) + .accessibilityLabel(label) + .accessibilityValue(textValue) + } + } + + private func commitText(source: String) { + textCommitCount += 1 + isTextFieldFocused = false + record("Text committed from \(source): \(draftText.isEmpty ? "empty" : draftText)") + } + + private func record(_ action: String) { + totalActions += 1 + lastAction = action } } + +private struct VerificationPanel: View { + let lastAction: String + let totalActions: Int + let selectedTab: String + let tabChangeCount: Int + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("LIVE", systemImage: "waveform.path.ecg") + .font(.caption.bold()) + .foregroundColor(.green) + .accessibilityIdentifier("harness-live-indicator") + Spacer() + Text("Total \(totalActions)") + .font(.caption.bold().monospacedDigit()) + .accessibilityIdentifier("total-action-count") + .accessibilityLabel("Total actions") + .accessibilityValue("\(totalActions)") + } + + Text(lastAction) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + .accessibilityIdentifier("last-action-status") + .accessibilityLabel("Last action") + .accessibilityValue(lastAction) + + HStack { + Text("Tab: \(selectedTab)") + .accessibilityIdentifier("selected-tab-status") + .accessibilityValue(selectedTab) + Spacer() + Text("Tab changes: \(tabChangeCount)") + .monospacedDigit() + .accessibilityIdentifier("tab-change-count") + .accessibilityValue("\(tabChangeCount)") + } + .font(.caption) + .foregroundColor(.secondary) + } + .padding(12) + .background(Color.green.opacity(0.10)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(Color.green.opacity(0.35), lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 12)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("verification-panel") + } +} + +#if canImport(UIKit) +private struct UIKitHarnessButton: UIViewRepresentable { + let count: Int + let action: () -> Void + + final class Coordinator: NSObject { + var action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func tapped() { + action() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + func makeUIView(context: Context) -> UIButton { + let button = UIButton(type: .system) + var configuration = UIButton.Configuration.filled() + configuration.cornerStyle = .medium + configuration.image = UIImage(systemName: "hand.tap.fill") + configuration.imagePadding = 8 + button.configuration = configuration + button.accessibilityIdentifier = "uikit-button" + button.accessibilityLabel = "UIKit button" + button.addTarget(context.coordinator, action: #selector(Coordinator.tapped), for: .touchUpInside) + return button + } + + func updateUIView(_ button: UIButton, context: Context) { + context.coordinator.action = action + var configuration = button.configuration ?? UIButton.Configuration.filled() + configuration.title = "UIKit Tap (\(count))" + button.configuration = configuration + button.accessibilityValue = "\(count) taps" + } +} +#endif diff --git a/test/fixtures/ios-qa/FixtureApp/project.yml b/test/fixtures/ios-qa/FixtureApp/project.yml index 35906f7b2..c3c172883 100644 --- a/test/fixtures/ios-qa/FixtureApp/project.yml +++ b/test/fixtures/ios-qa/FixtureApp/project.yml @@ -1,7 +1,7 @@ name: FixtureApp options: deploymentTarget: - iOS: "16.0" + iOS: "17.0" bundleIdPrefix: com.gstack.iosqa developmentLanguage: en createIntermediateGroups: true @@ -23,7 +23,7 @@ targets: FixtureApp: type: application platform: iOS - deploymentTarget: "16.0" + deploymentTarget: "17.0" sources: - path: Sources/FixtureApp dependencies: @@ -45,5 +45,5 @@ targets: CODE_SIGN_STYLE: Automatic TARGETED_DEVICE_FAMILY: "1" SWIFT_VERSION: "5.9" - IPHONEOS_DEPLOYMENT_TARGET: "16.0" + IPHONEOS_DEPLOYMENT_TARGET: "17.0" ENABLE_PREVIEWS: YES diff --git a/test/ios-qa-swiftui-tap-regression.test.ts b/test/ios-qa-swiftui-tap-regression.test.ts new file mode 100644 index 000000000..36aa7e924 --- /dev/null +++ b/test/ios-qa-swiftui-tap-regression.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const ROOT = join(import.meta.dir, '..'); +const PRE_FIXTURE = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json'); +const PRE_SCREENSHOT = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png'); + +describe('ios-fix regression fixture — SwiftUI taps reported success without acting', () => { + test('preserves the pre-fix state and physical-device screenshot', () => { + const state = JSON.parse(readFileSync(PRE_FIXTURE, 'utf8')); + expect(state).toEqual({ + _schema_version: 1, + _app_build_id: 'uninitialized', + _accessor_hash: 'uninitialized', + keys: {}, + }); + + const png = readFileSync(PRE_SCREENSHOT); + expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + expect(png.readUInt32BE(16)).toBe(1206); + expect(png.readUInt32BE(20)).toBe(2622); + }); + + test('keeps the physical-device deploy/tap test opt-in and executable', () => { + const deviceTest = readFileSync(join(ROOT, 'test/skill-e2e-ios-device.test.ts'), 'utf8'); + expect(deviceTest).toContain("process.env.GSTACK_IOS_DEVICE_DEPLOY === '1'"); + expect(deviceTest).toContain("'primary-button'"); + expect(deviceTest).toContain("'/tap'"); + expect(deviceTest).not.toContain("test.skip('TODO(deploy)"); + }); +}); diff --git a/test/skill-e2e-ios-device.test.ts b/test/skill-e2e-ios-device.test.ts index 1517be80c..200cb6557 100644 --- a/test/skill-e2e-ios-device.test.ts +++ b/test/skill-e2e-ios-device.test.ts @@ -1,4 +1,8 @@ -// GSTACK_HAS_IOS_DEVICE=1 device-path test. Runs only when: +// Real-device tests. The lightweight CoreDevice checks run with +// GSTACK_HAS_IOS_DEVICE=1; the signing/install/interaction smoke test has the +// separate, explicit GSTACK_IOS_DEVICE_DEPLOY=1 opt-in. +// +// Runs only when: // - An iPhone is connected via USB and reachable through CoreDevice // - The iPhone is paired (user has tapped "Trust" on the trust dialog) // - Developer Mode is enabled on the iPhone (Settings → Privacy → Developer Mode) @@ -10,57 +14,91 @@ // 4. The fixture iOS SPM package builds with `swift build` for iOS target // (verifies the templates compile against the iOS SDK, not just macOS) // -// What it does NOT exercise (out of scope for this test): -// - Building + signing a full iOS app via xcodebuild (requires provisioning -// profile + dev team — environment-specific, not portable across CI) -// - Actually deploying + launching the StateServer on the device (same) -// -// The first three steps prove the CoreDevice path is wired end-to-end on the -// agent's side. The fourth proves the Swift templates compile against the -// iOS SDK, not just macOS — which catches UIKit/SwiftUI gating bugs before -// they reach a real app deployment. +// GSTACK_IOS_DEVICE_DEPLOY=1 additionally generates the fixture Xcode project, +// signs it with GSTACK_IOS_DEVELOPMENT_TEAM + GSTACK_IOS_BUNDLE_ID, installs +// and launches it, then proves screenshot/elements/tap through the real daemon. +// It remains skipped in normal CI because signing and a paired iPhone are +// intentionally machine-specific. import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'fs'; +import { tmpdir } from 'os'; import { join } from 'path'; +import { startDaemon, type RunningDaemon } from '../ios-qa/daemon/src/index'; +import { startTunnelKeepalive } from '../ios-qa/daemon/src/devicectl'; +import { bootstrapTunnel } from '../ios-qa/daemon/src/tunnel-bootstrap'; +import type { DeviceTunnel } from '../ios-qa/daemon/src/proxy'; const ROOT = join(import.meta.dir, '..'); const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp'); const HAS_DEVICE = process.env.GSTACK_HAS_IOS_DEVICE === '1'; +const DEPLOY_TO_DEVICE = process.env.GSTACK_IOS_DEVICE_DEPLOY === '1'; const describeIfDevice = HAS_DEVICE ? describe : describe.skip; +const testIfDeploy = DEPLOY_TO_DEVICE ? test : test.skip; interface DeviceListEntry { identifier: string; state: string; // "available" | "available (pairing)" | "unavailable" | ... name: string; model: string; + platform: string; + transport: string; + paired: boolean; +} + +interface DeviceElement { + identifier?: string; + label?: string; + value?: string; + frame?: { x: number; y: number; w: number; h: number }; +} + +interface StateSnapshot { + _app_build_id?: string; + _accessor_hash?: string; + keys?: Record; } function listDevices(): DeviceListEntry[] { // devicectl JSON output requires --json-output to a path. Use a tempfile. const tmp = `/tmp/devicectl-list-${process.pid}-${Date.now()}.json`; - const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], { - stdio: 'pipe', - timeout: 30_000, - }); - if (r.status !== 0) return []; try { - const fs = require('fs'); - const raw = fs.readFileSync(tmp, 'utf-8'); + const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], { + stdio: 'pipe', + timeout: 30_000, + }); + if (r.status !== 0) return []; + const raw = readFileSync(tmp, 'utf-8'); const obj = JSON.parse(raw); - fs.unlinkSync(tmp); - return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string } }) => ({ + return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string; pairingState?: string; transportType?: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string; platform?: string } }) => ({ identifier: d.identifier, state: d.connectionProperties?.tunnelState ?? 'unknown', name: d.deviceProperties?.name ?? 'unknown', model: d.hardwareProperties?.productType ?? 'unknown', + platform: d.hardwareProperties?.platform ?? 'unknown', + transport: d.connectionProperties?.transportType ?? '', + paired: d.connectionProperties?.pairingState === 'paired', })); } catch { return []; + } finally { + try { unlinkSync(tmp); } catch { /* ignore */ } } } +function isAvailableIPhone(device: DeviceListEntry): boolean { + const state = device.state.trim().toLowerCase(); + const available = state === 'connected' + || state.startsWith('available') + || (state === 'disconnected' && device.transport.trim().toLowerCase() === 'wired'); + return available + && device.paired + && device.platform.toLowerCase() === 'ios' + && device.model.toLowerCase().startsWith('iphone'); +} + function isPaired(udid: string): boolean { // devicectl device info processes returns a clean exit when paired. const tmp = `/tmp/devicectl-info-${process.pid}-${Date.now()}.json`; @@ -69,12 +107,146 @@ function isPaired(udid: string): boolean { '-d', udid, '--json-output', tmp, ], { stdio: 'pipe', timeout: 30_000 }); - try { require('fs').unlinkSync(tmp); } catch { /* ignore */ } + try { unlinkSync(tmp); } catch { /* ignore */ } // Pair-required errors surface on stderr with "must be paired" or // CoreDeviceError 2. Treat any non-zero exit as not-paired. return r.status === 0; } +function requireDeployEnv(name: 'GSTACK_IOS_DEVELOPMENT_TEAM' | 'GSTACK_IOS_BUNDLE_ID'): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required when GSTACK_IOS_DEVICE_DEPLOY=1`); + } + return value; +} + +function runChecked( + command: string, + args: string[], + opts: { cwd?: string; timeout?: number } = {}, +): string { + const result = spawnSync(command, args, { + cwd: opts.cwd, + env: process.env, + stdio: 'pipe', + timeout: opts.timeout ?? 60_000, + maxBuffer: 32 * 1024 * 1024, + }); + const output = `${result.stdout?.toString() ?? ''}${result.stderr?.toString() ?? ''}`; + if (result.error || result.status !== 0) { + const tail = output.split('\n').slice(-120).join('\n'); + throw new Error([ + `${command} ${args.join(' ')} failed (${result.error?.message ?? `exit ${result.status}`})`, + tail, + ].filter(Boolean).join('\n')); + } + return output; +} + +async function daemonJson( + baseURL: string, + path: string, + init: RequestInit = {}, +): Promise<{ status: number; body: T; raw: string }> { + const response = await fetch(`${baseURL}${path}`, { + ...init, + signal: AbortSignal.timeout(60_000), + }); + const raw = await response.text(); + let body: T; + try { + body = JSON.parse(raw) as T; + } catch { + throw new Error(`${init.method ?? 'GET'} ${path} returned non-JSON HTTP ${response.status}: ${raw.slice(0, 500)}`); + } + return { status: response.status, body, raw }; +} + +function findElement(elements: DeviceElement[], identifier: string): DeviceElement | undefined { + return elements.find((element) => + element.identifier === identifier + && (element.frame?.w ?? 0) > 0 + && (element.frame?.h ?? 0) > 0, + ); +} + +type DeviceElementPredicate = (element: DeviceElement) => boolean; + +interface DeviceViewport { + w: number; + h: number; +} + +function isInsideViewport(element: DeviceElement, viewport: DeviceViewport): boolean { + const frame = element.frame; + if (!frame || frame.w <= 0 || frame.h <= 0) return false; + const centerX = frame.x + frame.w / 2; + const centerY = frame.y + frame.h / 2; + return centerX >= 0 && centerX <= viewport.w && centerY >= 0 && centerY <= viewport.h; +} + +async function readDeviceElements(baseURL: string): Promise { + const result = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements'); + if (result.status !== 200 || !Array.isArray(result.body.elements)) { + throw new Error(`GET /elements failed with HTTP ${result.status}: ${result.raw.slice(0, 500)}`); + } + return result.body.elements; +} + +async function waitForDeviceElement( + baseURL: string, + predicate: DeviceElementPredicate, + description: string, + options: { + condition?: DeviceElementPredicate; + tappableIn?: DeviceViewport; + timeoutMs?: number; + } = {}, +): Promise { + const deadline = Date.now() + (options.timeoutMs ?? 10_000); + let lastElements: DeviceElement[] = []; + while (Date.now() < deadline) { + lastElements = await readDeviceElements(baseURL); + const match = lastElements.find((element) => + predicate(element) + && (options.condition?.(element) ?? true) + && (!options.tappableIn || isInsideViewport(element, options.tappableIn)), + ); + if (match) return match; + await new Promise((resolve) => setTimeout(resolve, 200)); + } + const visible = lastElements + .filter((element) => element.identifier || element.label) + .slice(0, 80) + .map((element) => element.identifier ?? element.label) + .join(', '); + throw new Error(`timed out waiting for ${description}; last elements: ${visible}`); +} + +async function tapDeviceElement( + baseURL: string, + sessionId: string, + element: DeviceElement, +): Promise { + const frame = element.frame; + if (!frame) throw new Error('cannot tap an element without a frame'); + const tapped = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/tap', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-id': sessionId, + }, + body: JSON.stringify({ + x: frame.x + frame.w / 2, + y: frame.y + frame.h / 2, + }), + }); + if (tapped.status !== 200 || tapped.body.ok !== true) { + throw new Error(`tap failed with HTTP ${tapped.status}: ${tapped.raw.slice(0, 500)}`); + } +} + describeIfDevice('ios device path', () => { test('devicectl lists at least one connected device', () => { const devices = listDevices(); @@ -104,11 +276,9 @@ describeIfDevice('ios device path', () => { expect(paired.length).toBeGreaterThan(0); }); - test('fixture Swift package compiles for iOS target', () => { - // Use xcrun --sdk iphoneos to get the iOS SDK path, then pass it through - // to swift build via SDKROOT. This validates that the Swift templates - // (StateServer, DebugBridgeManager, DebugOverlay) compile against the - // iOS SDK — catches UIKit/SwiftUI gating bugs that macOS-only builds miss. + test('fixture iOS SDK and UIKit compile guards are available', () => { + // This is an environment + source-guard preflight. The explicit deployment + // test below performs the real signed iOS xcodebuild before installation. const sdkPath = spawnSync('xcrun', ['--sdk', 'iphoneos', '--show-sdk-path'], { stdio: 'pipe' }); if (sdkPath.status !== 0) { console.error('iOS SDK not found. Install via Xcode.'); @@ -117,16 +287,8 @@ describeIfDevice('ios device path', () => { const sdk = sdkPath.stdout.toString().trim(); expect(sdk).toContain('iPhoneOS'); - // Build the DebugBridgeUI target specifically for iOS. We can't use - // `swift build --triple arm64-apple-ios` directly because SwiftPM - // doesn't ship an iOS toolchain out of the box. The xcodebuild path - // requires a project — skip if no .xcodeproj exists. - // Instead, verify the iOS-only code compiles by parsing the canImport - // guards: if the template's `#if canImport(UIKit)` is wrong, the macOS - // build would have failed in the swift-build invariant test. The iOS - // SDK path being present is sufficient signal that the toolchain is - // installed; the deeper iOS-target build belongs to xcodebuild + a real - // app target, which is the "deploy to device" path documented below. + // SwiftPM cannot directly cross-build this UIKit package with the standalone + // host command, so keep the static guard assertion honest and narrowly named. const fs = require('fs') as typeof import('fs'); const overlay = fs.readFileSync( join(FIXTURE_PATH, 'Sources/DebugBridgeUI/DebugOverlay.swift'), @@ -137,26 +299,575 @@ describeIfDevice('ios device path', () => { expect(overlay).toContain('#endif'); }); - // Documented next step. Becomes a real test once we have: - // - test/fixtures/ios-qa/FixtureApp/FixtureApp.xcodeproj (or generated) - // - A signing certificate + provisioning profile on the test machine - // - GSTACK_IOS_DEVICE_DEPLOY=1 environment opt-in - // - // The flow would be: - // xcodebuild -scheme FixtureApp -destination 'platform=iOS,id=' \ - // -allowProvisioningUpdates build install - // xcrun devicectl device process launch -d --console - // # Scrape boot token from os_log - // curl http://[]:9999/healthz - // # ... full smoke loop ... - test.skip('TODO(deploy): build + deploy fixture to device + smoke test full StateServer loop', () => {}); +}); + +describe('ios device deployment (explicit opt-in)', () => { + testIfDeploy('generates, signs, installs, launches, and drives the fixture through the daemon', async () => { + const developmentTeam = requireDeployEnv('GSTACK_IOS_DEVELOPMENT_TEAM'); + const bundleId = requireDeployEnv('GSTACK_IOS_BUNDLE_ID'); + const devices = listDevices(); + const device = devices.find((candidate) => isAvailableIPhone(candidate) && isPaired(candidate.identifier)); + if (!device) { + const summary = devices.length > 0 + ? devices.map((d) => ` ${d.name} (${d.model}, ${d.platform}, ${d.identifier}): state=${d.state}, paired=${d.paired}`).join('\n') + : ' devicectl returned no devices'; + throw new Error([ + 'GSTACK_IOS_DEVICE_DEPLOY=1 requires an available, paired iPhone; stale unavailable devices are never selected.', + summary, + ].join('\n')); + } + + const workDir = mkdtempSync(join(tmpdir(), 'gstack-ios-device-deploy-')); + const fixtureDir = join(workDir, 'FixtureApp'); + const derivedData = join(workDir, 'DerivedData'); + let daemon: RunningDaemon | undefined; + let keepalive: { stop: () => void } | undefined; + let sessionId: string | undefined; + + try { + cpSync(FIXTURE_PATH, fixtureDir, { recursive: true }); + + // Exercise the same deterministic bootstrap that /ios-qa and /ios-sync + // install for users. This creates the app-owned typed accessor before + // XcodeGen discovers the fixture sources. + runChecked(join(ROOT, 'bin/gstack-ios-qa-regen'), [ + '--app-source', join(fixtureDir, 'Sources/FixtureApp'), + '--bridge-dir', fixtureDir, + ], { cwd: fixtureDir }); + const generatedAccessor = join( + fixtureDir, + 'Sources/FixtureApp/DebugBridgeGenerated/StateAccessor.swift', + ); + expect(existsSync(generatedAccessor)).toBe(true); + expect(readFileSync(generatedAccessor, 'utf8')).toContain('enum FixtureAppStateAccessor'); + + runChecked('xcodegen', [ + 'generate', + '--spec', join(fixtureDir, 'project.yml'), + '--project', fixtureDir, + '--project-root', fixtureDir, + ], { cwd: fixtureDir }); + + const projectPath = join(fixtureDir, 'FixtureApp.xcodeproj'); + expect(existsSync(projectPath)).toBe(true); + + runChecked('xcodebuild', [ + '-project', projectPath, + '-scheme', 'FixtureApp', + '-configuration', 'Debug', + '-destination', `platform=iOS,id=${device.identifier}`, + '-derivedDataPath', derivedData, + '-allowProvisioningUpdates', + `DEVELOPMENT_TEAM=${developmentTeam}`, + `PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`, + 'CODE_SIGN_STYLE=Automatic', + 'build', + ], { cwd: fixtureDir, timeout: 300_000 }); + + const appBundle = join(derivedData, 'Build/Products/Debug-iphoneos/FixtureApp.app'); + expect(existsSync(appBundle)).toBe(true); + const builtBundleId = runChecked('/usr/libexec/PlistBuddy', [ + '-c', 'Print :CFBundleIdentifier', + join(appBundle, 'Info.plist'), + ]).trim(); + expect(builtBundleId).toBe(bundleId); + + runChecked('xcrun', [ + 'devicectl', 'device', 'install', 'app', + '--device', device.identifier, + appBundle, + ], { timeout: 120_000 }); + + runChecked('xcrun', [ + 'devicectl', 'device', 'process', 'launch', + '--device', device.identifier, + '--terminate-existing', + bundleId, + ], { timeout: 60_000 }); + + keepalive = startTunnelKeepalive(device.identifier); + const bootstrap = await bootstrapTunnel({ + udid: device.identifier, + bundleId, + startupTimeoutMs: 30_000, + }); + if (!bootstrap.ok) { + throw new Error(`daemon tunnel bootstrap failed: ${bootstrap.error}${bootstrap.detail ? ` (${bootstrap.detail})` : ''}`); + } + + // The first provider call consumes the already-rotated bootstrap. Later + // calls perform a fresh bootstrap so the same daemon can recover after + // this app is relaunched and its in-memory bearer changes. + let pendingTunnel: DeviceTunnel | undefined = bootstrap.tunnel; + let tunnelProviderCalls = 0; + const provideTunnel = async (): Promise => { + tunnelProviderCalls += 1; + if (pendingTunnel) { + const first = pendingTunnel; + pendingTunnel = undefined; + return first; + } + const refreshed = await bootstrapTunnel({ + udid: device.identifier, + bundleId, + startupTimeoutMs: 30_000, + }); + if (!refreshed.ok) { + throw new Error(`daemon rebootstrap failed: ${refreshed.error}${refreshed.detail ? ` (${refreshed.detail})` : ''}`); + } + return refreshed.tunnel; + }; + + const started = await startDaemon({ + loopbackPort: 0, + tailnetEnabled: false, + pidfilePath: join(workDir, 'daemon.pid'), + tunnelProvider: provideTunnel, + }); + if ('error' in started) { + throw new Error(`daemon failed to start: ${started.error}${started.reason ? ` (${started.reason})` : ''}`); + } + daemon = started; + const baseURL = `http://127.0.0.1:${daemon.loopbackPort}`; + + const initialState = await daemonJson(baseURL, '/state/snapshot'); + expect(initialState.status).toBe(200); + expect(typeof initialState.body._app_build_id).toBe('string'); + expect(initialState.body._app_build_id).not.toBe('unknown'); + expect(initialState.body._app_build_id).not.toBe('uninitialized'); + expect(initialState.body._accessor_hash).toMatch(/^[a-f0-9]{64}$/); + expect(initialState.body._accessor_hash).not.toBe('uninitialized'); + expect(Object.keys(initialState.body.keys ?? {}).sort()).toEqual([ + 'isLoggedIn', + 'nickname', + 'tapCounter', + 'username', + ]); + expect(initialState.body.keys?.nickname).toBeNull(); + + const screenshot = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot'); + expect(screenshot.status).toBe(200); + expect(typeof screenshot.body.png_base64).toBe('string'); + const png = Buffer.from(screenshot.body.png_base64!, 'base64'); + expect(png.length).toBeGreaterThan(1_000); + expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + + const requiredIdentifiers = [ + 'primary-button', + 'toolbar-actions-menu', + 'open-detail-button', + 'tab-controls', + 'tab-inputs', + 'tab-rows', + ]; + let elementsBefore: DeviceElement[] = []; + let identifiers = new Set(); + const elementsDeadline = Date.now() + 10_000; + while (Date.now() < elementsDeadline) { + const before = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements'); + expect(before.status).toBe(200); + expect(Array.isArray(before.body.elements)).toBe(true); + elementsBefore = before.body.elements ?? []; + identifiers = new Set( + elementsBefore.map((element) => element.identifier).filter((value): value is string => Boolean(value)), + ); + if (requiredIdentifiers.every((identifier) => identifiers.has(identifier))) break; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + expect(elementsBefore.length).toBeGreaterThan(30); + expect(requiredIdentifiers.filter((identifier) => !identifiers.has(identifier))).toEqual([]); + const appFrame = findElement(elementsBefore, 'fixture-tab-view')?.frame; + expect(appFrame).toBeDefined(); + // Screenshot pixels and /tap coordinates must share UIKit's point + // space. A 3x PNG here recreates the original missed-tap bug. + expect(png.readUInt32BE(16)).toBe(appFrame!.w); + expect(png.readUInt32BE(20)).toBe(appFrame!.h); + + const buttonBefore = findElement(elementsBefore, 'primary-button'); + expect(buttonBefore).toBeDefined(); + expect(typeof buttonBefore!.value).toBe('string'); + + const acquired = await daemonJson<{ session_id?: string }>(baseURL, '/session/acquire', { method: 'POST' }); + expect(acquired.status).toBe(200); + expect(typeof acquired.body.session_id).toBe('string'); + sessionId = acquired.body.session_id!; + + const rejectedBooleanAsInteger = await daemonJson<{ error?: string }>(baseURL, '/state/tapCounter', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-id': sessionId, + }, + body: JSON.stringify({ value: true }), + }); + expect(rejectedBooleanAsInteger.status).toBe(400); + expect(rejectedBooleanAsInteger.body.error).toBe('type_mismatch'); + + const rejectedIntegerAsBoolean = await daemonJson<{ error?: string }>(baseURL, '/state/isLoggedIn', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-id': sessionId, + }, + body: JSON.stringify({ value: 1 }), + }); + expect(rejectedIntegerAsBoolean.status).toBe(400); + expect(rejectedIntegerAsBoolean.body.error).toBe('type_mismatch'); + const afterRejectedCoercions = await daemonJson(baseURL, '/state/snapshot'); + expect(afterRejectedCoercions.body.keys?.tapCounter).toBe(0); + expect(afterRejectedCoercions.body.keys?.isLoggedIn).toBe(false); + + const wroteState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/tapCounter', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-id': sessionId, + }, + body: JSON.stringify({ value: 7 }), + }); + expect(wroteState.status).toBe(200); + expect(wroteState.body).toEqual({ ok: true }); + const updatedState = await daemonJson(baseURL, '/state/snapshot'); + expect(updatedState.status).toBe(200); + expect(updatedState.body.keys?.tapCounter).toBe(7); + + const wroteOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-id': sessionId, + }, + body: JSON.stringify({ value: 'Device' }), + }); + expect(wroteOptional.status).toBe(200); + const optionalValue = await daemonJson(baseURL, '/state/snapshot'); + expect(optionalValue.body.keys?.nickname).toBe('Device'); + + const clearedOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-id': sessionId, + }, + body: JSON.stringify({ value: null }), + }); + expect(clearedOptional.status).toBe(200); + const clearedValue = await daemonJson(baseURL, '/state/snapshot'); + expect(clearedValue.body.keys?.nickname).toBeNull(); + + const restoredState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/restore', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-id': sessionId, + }, + body: JSON.stringify(initialState.body), + }); + expect(restoredState.status).toBe(200); + expect(restoredState.body).toEqual({ ok: true }); + const afterRestore = await daemonJson(baseURL, '/state/snapshot'); + expect(afterRestore.body.keys?.tapCounter).toBe(0); + expect(afterRestore.body.keys?.nickname).toBeNull(); + + const viewport = { w: appFrame!.w, h: appFrame!.h }; + const byIdentifier = (identifier: string): DeviceElementPredicate => + (element) => element.identifier === identifier; + const byLabel = (label: string): DeviceElementPredicate => + (element) => element.label?.trim() === label; + + const tapAndWaitForValueChange = async ( + target: DeviceElementPredicate, + oracle: DeviceElementPredicate, + description: string, + ): Promise => { + const before = await waitForDeviceElement( + baseURL, + oracle, + `${description} oracle before tap`, + { condition: (element) => typeof element.value === 'string' }, + ); + const targetElement = await waitForDeviceElement( + baseURL, + target, + `${description} target`, + { tappableIn: viewport }, + ); + await tapDeviceElement(baseURL, sessionId!, targetElement); + const after = await waitForDeviceElement( + baseURL, + oracle, + `${description} value change`, + { condition: (element) => typeof element.value === 'string' && element.value !== before.value }, + ); + expect(after.value).not.toBe(before.value); + return after; + }; + + const firstInteger = (value: string | undefined): number | undefined => { + const match = value?.match(/-?\d+/); + return match ? Number(match[0]) : undefined; + }; + + const tapAndWaitForCountIncrement = async ( + target: DeviceElementPredicate, + oracle: DeviceElementPredicate, + description: string, + ): Promise => { + const before = await waitForDeviceElement( + baseURL, + oracle, + `${description} counter before tap`, + { condition: (element) => firstInteger(element.value) !== undefined }, + ); + const beforeCount = firstInteger(before.value)!; + const targetElement = await waitForDeviceElement( + baseURL, + target, + `${description} target`, + { tappableIn: viewport }, + ); + await tapDeviceElement(baseURL, sessionId!, targetElement); + const after = await waitForDeviceElement( + baseURL, + oracle, + `${description} exactly-once counter increment`, + { condition: (element) => firstInteger(element.value) === beforeCount + 1 }, + ); + expect(firstInteger(after.value)).toBe(beforeCount + 1); + return after; + }; + + // SwiftUI button styles and both navigation-bar controls. + for (const identifier of [ + 'primary-button', + 'bordered-button', + 'plain-button', + 'destructive-button', + 'nav-refresh-button', + ]) { + await tapAndWaitForCountIncrement( + byIdentifier(identifier), + byIdentifier(identifier), + identifier, + ); + } + + // Menu presentation and both menu commands. The menu's own value is the + // stable oracle after each transient command element disappears. + for (const commandIdentifier of ['menu-add-item', 'menu-archive-item']) { + const menuBefore = await waitForDeviceElement( + baseURL, + byIdentifier('toolbar-actions-menu'), + 'toolbar menu value', + { condition: (element) => typeof element.value === 'string' }, + ); + const menu = await waitForDeviceElement( + baseURL, + byIdentifier('toolbar-actions-menu'), + 'toolbar actions menu', + { tappableIn: viewport }, + ); + await tapDeviceElement(baseURL, sessionId, menu); + const command = await waitForDeviceElement( + baseURL, + byIdentifier(commandIdentifier), + commandIdentifier, + { tappableIn: viewport }, + ); + await tapDeviceElement(baseURL, sessionId, command); + const beforeCounts = [...(menuBefore.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0])); + expect(beforeCounts).toHaveLength(2); + const changedIndex = commandIdentifier === 'menu-add-item' ? 0 : 1; + const expectedCounts = beforeCounts.map((count, index) => count + (index === changedIndex ? 1 : 0)); + const menuAfter = await waitForDeviceElement( + baseURL, + byIdentifier('toolbar-actions-menu'), + `${commandIdentifier} exactly-once result`, + { + condition: (element) => { + const counts = [...(element.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0])); + return counts.length === 2 && counts.every((count, index) => count === expectedCounts[index]); + }, + }, + ); + const afterCounts = [...(menuAfter.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0])); + expect(afterCounts).toEqual(expectedCounts); + } + + // Push, interact with, and pop the explicit navigation destination. + const openDetail = await waitForDeviceElement( + baseURL, + byIdentifier('open-detail-button'), + 'open detail button', + { tappableIn: viewport }, + ); + await tapDeviceElement(baseURL, sessionId, openDetail); + await waitForDeviceElement(baseURL, byIdentifier('detail-screen-title'), 'detail destination'); + await tapAndWaitForCountIncrement( + byIdentifier('detail-action-button'), + byIdentifier('detail-action-button'), + 'detail action button', + ); + const back = await waitForDeviceElement( + baseURL, + byIdentifier('detail-back-button'), + 'detail back button', + { tappableIn: viewport }, + ); + await tapDeviceElement(baseURL, sessionId, back); + await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'controls after detail pop'); + + // Tab navigation plus native toggle, stepper, segmented picker, text + // input/commit, and a UIKit UIButton. + const inputsTab = await waitForDeviceElement( + baseURL, + byIdentifier('tab-inputs'), + 'Inputs tab', + { tappableIn: viewport }, + ); + await tapDeviceElement(baseURL, sessionId, inputsTab); + await waitForDeviceElement(baseURL, byIdentifier('harness-toggle'), 'Inputs controls'); + await tapAndWaitForValueChange( + byIdentifier('harness-toggle'), + byIdentifier('harness-toggle'), + 'toggle', + ); + await tapAndWaitForCountIncrement( + byIdentifier('harness-stepper-Increment'), + byIdentifier('harness-stepper'), + 'stepper increment', + ); + const segmentTwo = await waitForDeviceElement( + baseURL, + byLabel('Two'), + 'segmented picker option Two', + { tappableIn: viewport }, + ); + await tapDeviceElement(baseURL, sessionId, segmentTwo); + const selectedTwo = await waitForDeviceElement( + baseURL, + byIdentifier('harness-segmented-picker'), + 'segmented picker selection', + { condition: (element) => element.value?.includes('Two') === true }, + ); + expect(selectedTwo.value).toContain('Two'); + + const textField = await waitForDeviceElement( + baseURL, + byIdentifier('harness-text-field'), + 'text field', + { tappableIn: viewport }, + ); + await tapDeviceElement(baseURL, sessionId, textField); + const typed = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/type', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-id': sessionId, + }, + body: JSON.stringify({ text: 'device matrix' }), + }); + expect(typed.status).toBe(200); + expect(typed.body).toMatchObject({ op: 'type', ok: true }); + await waitForDeviceElement( + baseURL, + byIdentifier('harness-text-field'), + 'typed text value', + { condition: (element) => element.value === 'device matrix' }, + ); + await tapAndWaitForCountIncrement( + byIdentifier('commit-text-button'), + byIdentifier('commit-text-button'), + 'text commit button', + ); + // Commit clears FocusState; let the keyboard dismissal animation finish + // before choosing a scroll-view hit point for the UIKit control below. + await new Promise((resolve) => setTimeout(resolve, 500)); + + const scrollInputs = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/swipe', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-id': sessionId, + }, + body: JSON.stringify({ from_x: 200, from_y: 650, to_x: 200, to_y: 220 }), + }); + expect(scrollInputs.status).toBe(200); + expect(scrollInputs.body).toMatchObject({ op: 'swipe', ok: true }); + await tapAndWaitForCountIncrement( + byIdentifier('uikit-button'), + byIdentifier('uikit-button'), + 'UIKit button', + ); + + // All four list rows and the row navigation-bar action. + const rowsTab = await waitForDeviceElement( + baseURL, + byIdentifier('tab-rows'), + 'Rows tab', + { tappableIn: viewport }, + ); + await tapDeviceElement(baseURL, sessionId, rowsTab); + await waitForDeviceElement(baseURL, byIdentifier('row-alpha-button'), 'Rows list'); + for (const row of ['alpha', 'bravo', 'charlie', 'delta']) { + await tapAndWaitForCountIncrement( + byIdentifier(`row-${row}-button`), + byIdentifier(`row-${row}-button`), + `${row} row`, + ); + } + await tapAndWaitForCountIncrement( + byIdentifier('rows-toolbar-button'), + byIdentifier('rows-toolbar-button'), + 'rows toolbar button', + ); + + const controlsTab = await waitForDeviceElement( + baseURL, + byIdentifier('tab-controls'), + 'Controls tab', + { tappableIn: viewport }, + ); + await tapDeviceElement(baseURL, sessionId, controlsTab); + await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'Controls tab restored'); + + // Keep the daemon alive while the app process gets a new boot token. + // The first proxied request must observe the stale bearer, invalidate + // only that tunnel, single-flight a fresh bootstrap, and retry once. + runChecked('xcrun', [ + 'devicectl', 'device', 'process', 'launch', + '--device', device.identifier, + '--terminate-existing', + bundleId, + ], { timeout: 60_000 }); + await new Promise((resolve) => setTimeout(resolve, 500)); + const afterRelaunch = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot'); + expect(afterRelaunch.status).toBe(200); + expect(Buffer.from(afterRelaunch.body.png_base64 ?? '', 'base64').length).toBeGreaterThan(1_000); + expect(tunnelProviderCalls).toBe(2); + const stateAfterRelaunch = await daemonJson(baseURL, '/state/snapshot'); + expect(stateAfterRelaunch.status).toBe(200); + expect(stateAfterRelaunch.body._accessor_hash).toBe(initialState.body._accessor_hash); + } finally { + if (daemon && sessionId) { + await daemonJson(`http://127.0.0.1:${daemon.loopbackPort}`, '/session/release', { method: 'POST' }).catch(() => undefined); + } + if (daemon) await daemon.close(); + keepalive?.stop(); + rmSync(workDir, { recursive: true, force: true }); + } + }, 600_000); }); // Always-on instructions if not paired. Surfaces actionable steps even when // the test is opted in via env var but the device isn't ready. if (HAS_DEVICE) { const devices = listDevices(); - const unpaired = devices.filter(d => !isPaired(d.identifier)); + const unpaired = devices.filter(d => + d.platform.toLowerCase() === 'ios' + && d.model.toLowerCase().startsWith('iphone') + && !d.paired, + ); if (unpaired.length > 0) { console.error(''); console.error('=== iOS DEVICE PAIRING REQUIRED ==='); diff --git a/test/skill-e2e-ios-swift-build.test.ts b/test/skill-e2e-ios-swift-build.test.ts index 6b4242f5e..253b4cb84 100644 --- a/test/skill-e2e-ios-swift-build.test.ts +++ b/test/skill-e2e-ios-swift-build.test.ts @@ -19,7 +19,7 @@ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; -import { existsSync, readFileSync } from 'fs'; +import { readFileSync } from 'fs'; import { join } from 'path'; const ROOT = join(import.meta.dir, '..'); @@ -27,31 +27,82 @@ const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp'); const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates'); const GEN_ACCESSORS_PACKAGE = join(ROOT, 'ios-qa/scripts/gen-accessors-tool/Package.swift'); -// 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); - }); +const COPIED_BRIDGE_TEMPLATES = [ + ['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'], + ['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'], + ['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'], + ['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'], + ['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'], + ['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'], +] as const; - 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); +function readTemplate(name: string): string { + return readFileSync(join(TEMPLATES_PATH, name), 'utf-8'); +} + +function normalizeBridgePackage(source: string): string { + // Package.swift.template has a generated-file prologue while the fixture has + // a fixture-specific one. The tools-version declaration must remain first in + // both real files, but neither header is part of the copied bridge surface. + const importOffset = source.indexOf('import PackageDescription'); + expect(importOffset).toBeGreaterThanOrEqual(0); + let packageBody = source.slice(importOffset); + + // The fixture deliberately has its own package identity and XCTest target. + // Normalize only those fixture concerns; all three bridge products, targets, + // dependencies, settings, and paths must otherwise stay in lockstep. + packageBody = packageBody.replace( + /(let package = Package\(\s*name:)\s*"[^"]+"/, + '$1 ""', + ); + packageBody = packageBody.replace( + /\n\s*\.testTarget\(\s*\n\s*name:\s*"DebugBridgeCoreTests",[\s\S]*?\n\s{8}\),?/, + '', + ); + + // Ignore prose and formatting so a template-only explanatory comment does + // not conceal a meaningful manifest mismatch. + return packageBody + .replace(/\/\/.*$/gm, '') + .replace(/\s+/g, '') + .replace(/,([\])])/g, '$1'); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function bracedBlock(source: string, openBraceOffset: number): string { + let depth = 0; + for (let offset = openBraceOffset; offset < source.length; offset++) { + if (source[offset] === '{') depth++; + if (source[offset] !== '}') continue; + depth--; + if (depth === 0) return source.slice(openBraceOffset, offset + 1); + } + return ''; +} + +// The fixture is where the bridge is compiled and exercised end-to-end. Every +// source copied into consuming apps must therefore be the canonical template, +// or device QA can pass against code that /ios-qa never installs. +describe('template ↔ fixture parity', () => { + for (const [templateName, fixtureDestination] of COPIED_BRIDGE_TEMPLATES) { + test(`${templateName} matches ${fixtureDestination}`, () => { + expect(readTemplate(templateName)).toBe( + readFileSync(join(FIXTURE_PATH, fixtureDestination), 'utf-8'), + ); + }); + } + + test('Package.swift bridge declarations match after fixture-only normalization', () => { + const template = readTemplate('Package.swift.template'); + const fixture = readFileSync(join(FIXTURE_PATH, 'Package.swift'), 'utf-8'); + expect(normalizeBridgePackage(template)).toBe(normalizeBridgePackage(fixture)); }); test('Package.swift.template declares all 3 DebugBridge targets', () => { - const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); + const tmpl = readTemplate('Package.swift.template'); // 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}"`); @@ -70,11 +121,182 @@ describe('template ↔ fixture parity', () => { }); test('Package.swift.template keeps swift-tools-version on the first line', () => { - const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); + const tmpl = readTemplate('Package.swift.template'); expect(tmpl.split(/\r?\n/, 1)[0]).toBe('// swift-tools-version:5.9'); }); }); +describe('iOS tap harness regressions', () => { + test('manager receives app-owned generated accessors instead of a no-op package stub', () => { + const manager = readTemplate('DebugBridgeManager.swift.template'); + const wiring = readTemplate('DebugBridgeWiring.swift.template'); + const fixtureApp = readFileSync( + join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppApp.swift'), + 'utf-8', + ); + + expect(manager).toContain('func start'); + expect(manager).toContain('register: (State) -> Void'); + expect(manager).toContain('register(appState)'); + expect(manager).not.toContain('public enum AppStateAccessor'); + expect(manager).not.toContain('protocol AppState'); + + expect(wiring).toContain('import DebugBridgeCore'); + expect(wiring).toContain('import DebugBridgeUI'); + expect(wiring).toContain('DebugBridgeUIWiring.installAll()'); + expect(wiring.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan( + wiring.indexOf('DebugBridgeManager.shared.start'), + ); + expect(fixtureApp.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan( + fixtureApp.indexOf('DebugBridgeManager.shared.start'), + ); + expect(wiring).not.toContain('import DebugBridge\n'); + expect(wiring).not.toContain('AccessibilityScanner'); + expect(wiring).not.toContain('MutationDispatcher'); + }); + + test('fixture uses an @Observable-compatible source marker, not a property wrapper', () => { + const state = readFileSync( + join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppState.swift'), + 'utf-8', + ); + expect(state).toContain('@Observable'); + expect(state.match(/\/\/ @Snapshotable/g)?.length).toBe(4); + expect(state).not.toContain('@propertyWrapper'); + expect(state).not.toMatch(/^[\t ]*@Snapshotable[\t ]+(?:public[\t ]+)?var/m); + }); + + test('recurses through iOS automation elements to expose nested SwiftUI controls', () => { + const bridges = readTemplate('Bridges.swift.template'); + expect(bridges).toContain('element.automationElements'); + expect(bridges).toContain('debugBridgeAccessibilityChildren(of: element)'); + expect(bridges).toContain('visited.insert(ObjectIdentifier(element)).inserted'); + expect(bridges).toContain('var remaining = 2_048'); + }); + + test('enables accessibility automation before SwiftUI AX is installed', () => { + const implementation = readTemplate('DebugBridgeTouch.m.template'); + const bridges = readTemplate('Bridges.swift.template'); + const helper = [...implementation.matchAll( + /static\s+void\s+([A-Za-z_]\w*)\s*\(\s*void\s*\)\s*\{/g, + )].find((candidate) => { + const body = bracedBlock( + implementation, + candidate.index! + candidate[0].lastIndexOf('{'), + ); + return body.includes('_AXSAutomationEnabled') && body.includes('_AXSSetAutomationEnabled'); + }); + expect(helper).toBeDefined(); + + const helperName = helper![1]; + const helperBody = bracedBlock( + implementation, + helper!.index! + helper![0].lastIndexOf('{'), + ); + expect(helperBody).toContain('_AXSAutomationEnabled'); + expect(helperBody).toContain('_AXSSetAutomationEnabled'); + + // Accept either Objective-C's eager +load hook or an explicit public + // bootstrap selector, but require the enabling helper to be called before + // Swift installs the resolver that walks SwiftUI's accessibility tree. + const bootstrap = [...implementation.matchAll(/\+\s*\(void\)\s*([A-Za-z_]\w*)\s*\{/g)] + .find((candidate) => bracedBlock( + implementation, + candidate.index! + candidate[0].lastIndexOf('{'), + ).match(new RegExp(`\\b${escapeRegExp(helperName)}\\s*\\(`))); + expect(bootstrap).toBeDefined(); + + if (bootstrap![1] === 'load') { + expect(bootstrap!.index!).toBeLessThan(implementation.indexOf('+ (BOOL)sendTapAtPoint:')); + } else { + const bootstrapCall = bridges.search( + new RegExp(`DebugBridgeTouch\\.${escapeRegExp(bootstrap![1])}\\s*\\(`), + ); + expect(bootstrapCall).toBeGreaterThanOrEqual(0); + expect(bootstrapCall).toBeLessThan(bridges.indexOf('ElementsBridge.resolver')); + } + }); + + test('renders screenshots at one pixel per window point', () => { + const bridges = readTemplate('Bridges.swift.template'); + const declaration = bridges.match( + /(?:let|var)\s+([A-Za-z_]\w*)\s*=\s*UIGraphicsImageRendererFormat(?:\.default)?\(\)/, + ); + expect(declaration).not.toBeNull(); + + const formatName = declaration![1]; + const scalePattern = new RegExp(`\\b${escapeRegExp(formatName)}\\.scale\\s*=\\s*1(?:\\.0)?\\b`); + const rendererPattern = new RegExp( + `UIGraphicsImageRenderer\\(\\s*bounds:\\s*bounds,\\s*format:\\s*${escapeRegExp(formatName)}\\s*\\)`, + ); + const declarationOffset = declaration!.index!; + const scaleOffset = bridges.search(scalePattern); + const rendererOffset = bridges.search(rendererPattern); + + expect(scaleOffset).toBeGreaterThan(declarationOffset); + expect(rendererOffset).toBeGreaterThan(scaleOffset); + }); + + test('uses accessibilityActivate for SwiftUI while retaining synthesized-touch delivery', () => { + const bridges = readTemplate('Bridges.swift.template'); + const tapStart = bridges.indexOf('private static func handleTap'); + const tapEnd = bridges.indexOf('private static func handleType', tapStart); + expect(tapStart).toBeGreaterThanOrEqual(0); + expect(tapEnd).toBeGreaterThan(tapStart); + const handleTap = bridges.slice(tapStart, tapEnd); + + const synthesizedTouchOffset = handleTap.indexOf('DebugBridgeTouch.sendTap'); + const fallbackCall = handleTap.match( + /\b([A-Za-z_]\w*)\s*\(\s*at:\s*point\s*,\s*in:\s*window\s*\)/, + ); + const activationOffset = handleTap.indexOf('.accessibilityActivate()'); + expect(synthesizedTouchOffset).toBeGreaterThanOrEqual(0); + expect(fallbackCall).not.toBeNull(); + expect(activationOffset).toBeGreaterThan(fallbackCall!.index!); + + const fallbackName = fallbackCall![1]; + expect(bridges).toMatch( + new RegExp(`(?:private\\s+)?static\\s+func\\s+${escapeRegExp(fallbackName)}\\s*\\(`), + ); + }); + + test('finishes programmatic scrolls before returning success to the next tap', () => { + const bridges = readTemplate('Bridges.swift.template'); + expect(bridges).toContain('setContentOffset(off, animated: false)'); + expect(bridges).not.toContain('setContentOffset(off, animated: true)'); + }); + + test('serializes accessibility traits without signed Int truncation', () => { + const bridges = readTemplate('Bridges.swift.template'); + expect(bridges).toMatch(/\btraits\s*:\s*UInt64\b/); + expect(bridges).toContain('.uint64Value'); + expect(bridges).not.toMatch(/\bInt(?:64)?\s*\(\s*view\.accessibilityTraits\.rawValue\s*\)/); + expect(bridges).not.toMatch(/accessibilityTraits[\s\S]{0,120}?\.intValue\b/); + }); + + test('validates every generated model before applying any snapshot state', () => { + const server = readTemplate('StateServer.swift.template'); + const validationLoop = server.indexOf('for restore in atomicRestores'); + const applyComment = server.indexOf('Phase two applies only after every model accepted'); + const applyLoop = server.indexOf('for restore in atomicRestores', validationLoop + 1); + + expect(server).toContain('typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult'); + expect(server).toContain('restore(keys, false)'); + expect(server).toContain('restore(keys, true)'); + expect(validationLoop).toBeGreaterThanOrEqual(0); + expect(applyComment).toBeGreaterThan(validationLoop); + expect(applyLoop).toBeGreaterThan(applyComment); + }); + + test('turns non-JSON response bodies into an explicit HTTP 500', () => { + const server = readTemplate('StateServer.swift.template'); + expect(server).toContain('JSONSerialization.isValidJSONObject(body)'); + expect(server).toContain('responseStatus = 500'); + expect(server).toContain('response_not_json_serializable'); + expect(server).not.toContain('?? Data("{}".utf8)'); + }); +}); + function hasSwift(): boolean { const r = spawnSync('swift', ['--version'], { stdio: 'pipe' }); return r.status === 0; diff --git a/test/skill-e2e-ios.test.ts b/test/skill-e2e-ios.test.ts index 8d8f09c56..56211637a 100644 --- a/test/skill-e2e-ios.test.ts +++ b/test/skill-e2e-ios.test.ts @@ -171,9 +171,12 @@ describe('ios-qa E2E (no-device path)', () => { writeFileSync(join(srcDir, 'AppState.swift'), ` @Observable class AppState { - @Snapshotable var isLoggedIn: Bool = false - @Snapshotable var username: String = "" - @Snapshotable var counter: Int = 0 + // @Snapshotable + var isLoggedIn: Bool = false + // @Snapshotable + var username: String = "" + // @Snapshotable + var counter: Int = 0 var ephemeralCache: [String: Any] = [:] } `); @@ -189,7 +192,8 @@ class AppState { expect(result.specs).toHaveLength(1); expect(result.specs[0]!.fields.map(f => f.name).sort()).toEqual(['counter', 'isLoggedIn', 'username']); const generatedSwift = readFileSync(result.outputPath, 'utf-8'); - expect(generatedSwift).toContain('public enum AppStateAccessor'); + expect(generatedSwift).toContain('enum AppStateAccessor'); + expect(generatedSwift).not.toContain('public enum AppStateAccessor'); expect(generatedSwift).toContain('key: "isLoggedIn"'); expect(generatedSwift).toContain('key: "counter"'); expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable