mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-18 13:37:23 +02:00
fix(ios-qa): make SwiftUI device taps observable and reliable
This commit is contained in:
@@ -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..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
|
||||
}
|
||||
|
||||
// MARK: - ScreenshotBridge implementation
|
||||
|
||||
@MainActor
|
||||
@@ -43,7 +66,11 @@ enum ScreenshotBridgeImpl {
|
||||
static func capturePNG() -> 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<ObjectIdentifier>()
|
||||
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<ObjectIdentifier>,
|
||||
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) > <synthetic>",
|
||||
"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..<count {
|
||||
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
|
||||
if let v = element as? UIView {
|
||||
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
|
||||
} else {
|
||||
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
elements.append([
|
||||
"path": "\(path) > <ax\(i)>",
|
||||
"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) > <ax\(index)>",
|
||||
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<ObjectIdentifier>,
|
||||
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) > <ax\(index)>",
|
||||
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<ObjectIdentifier>()
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"_schema_version": 1,
|
||||
"_app_build_id": "uninitialized",
|
||||
"_accessor_hash": "uninitialized",
|
||||
"keys": {}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
+44
-2
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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).
|
||||
|
||||
+205
-74
@@ -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..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
|
||||
}
|
||||
|
||||
// MARK: - ScreenshotBridge implementation
|
||||
|
||||
@MainActor
|
||||
@@ -43,7 +66,11 @@ enum ScreenshotBridgeImpl {
|
||||
static func capturePNG() -> 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<ObjectIdentifier>()
|
||||
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<ObjectIdentifier>,
|
||||
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) > <synthetic>",
|
||||
"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..<count {
|
||||
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
|
||||
if let v = element as? UIView {
|
||||
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
|
||||
} else {
|
||||
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
elements.append([
|
||||
"path": "\(path) > <ax\(i)>",
|
||||
"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) > <ax\(index)>",
|
||||
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<ObjectIdentifier>,
|
||||
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) > <ax\(index)>",
|
||||
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<ObjectIdentifier>()
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? "<empty>" : 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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<T>(
|
||||
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<DeviceElement[]> {
|
||||
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<DeviceElement> {
|
||||
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<void> {
|
||||
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=<UDID>' \
|
||||
// -allowProvisioningUpdates build install
|
||||
// xcrun devicectl device process launch -d <UDID> --console <bundle-id>
|
||||
// # Scrape boot token from os_log
|
||||
// curl http://[<corodevice-ipv6>]: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<DeviceTunnel | null> => {
|
||||
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<StateSnapshot>(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<string>();
|
||||
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<StateSnapshot>(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<StateSnapshot>(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<StateSnapshot>(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<StateSnapshot>(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<StateSnapshot>(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<DeviceElement> => {
|
||||
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<DeviceElement> => {
|
||||
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<StateSnapshot>(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 ===');
|
||||
|
||||
@@ -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 "<bridge-package>"',
|
||||
);
|
||||
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<State>');
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user