From 145fe2d4d126e2f1f9d4f14f9dd32dafd336396f Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 16:35:30 -0700 Subject: [PATCH] fix(ios-qa): make SwiftUI device taps observable and reliable --- ios-qa/templates/Bridges.swift.template | 279 ++++-- ios-qa/templates/DebugBridgeTouch.h.template | 4 + ios-qa/templates/DebugBridgeTouch.m.template | 46 +- .../ios-fix/ios-qa-swiftui-tap-pre.json | 6 + .../ios-fix/ios-qa-swiftui-tap-pre.png | Bin 0 -> 97916 bytes .../DebugBridgeTouch/DebugBridgeTouch.m | 46 +- .../include/DebugBridgeTouch.h | 4 + .../Sources/DebugBridgeUI/Bridges.swift | 279 ++++-- .../Sources/FixtureApp/FixtureAppApp.swift | 738 +++++++++++++++- test/fixtures/ios-qa/FixtureApp/project.yml | 6 +- test/ios-qa-swiftui-tap-regression.test.ts | 32 + test/skill-e2e-ios-device.test.ts | 809 ++++++++++++++++-- test/skill-e2e-ios-swift-build.test.ts | 268 +++++- test/skill-e2e-ios.test.ts | 12 +- 14 files changed, 2278 insertions(+), 251 deletions(-) create mode 100644 test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json create mode 100644 test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png create mode 100644 test/ios-qa-swiftui-tap-regression.test.ts 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 0000000000000000000000000000000000000000..c5f22e90ece360e7f5967e50e145d06534021830 GIT binary patch literal 97916 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 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