diff --git a/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json b/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json deleted file mode 100644 index dd6ec5a68..000000000 --- a/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "_schema_version": 1, - "_app_build_id": "uninitialized", - "_accessor_hash": "uninitialized", - "keys": {} -} diff --git a/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png b/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png deleted file mode 100644 index c5f22e90e..000000000 Binary files a/test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png and /dev/null differ diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift index 8dfb96d88..e18e2fb05 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift @@ -14,16 +14,12 @@ import Foundation public final class DebugBridgeManager { public static let shared = DebugBridgeManager() - /// Register app-owned generated accessors, then start the server. The - /// registration closure is passed in from the consuming app because the - /// DebugBridgeCore package cannot import app-target types. On UIKit apps, - /// call DebugBridgeUIWiring.installAll() before this method so a warm - /// daemon cannot reach uninitialized resolvers during listener startup. - public func start(appState: State, register: (State) -> Void) { - register(appState) + public func start(appState: AppState) { + // 1. Register the canonical AppState struct + accessor wiring. + // AppStateAccessor.register(_:) is generated by gen-accessors-tool. + AppStateAccessor.register(appState) - // Boot only after registration so the first snapshot has a real build - // id, schema hash, and key set. + // 2. Boot the StateServer. StateServer.shared.start() // 3. The consuming app installs DebugOverlayWindow separately. See @@ -35,4 +31,19 @@ public final class DebugBridgeManager { } } +// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next +// to the app's canonical state struct. Apps that haven't run codegen get a +// stub that registers no accessors (snapshot is empty, restore returns +// missing-key for every key). +@MainActor +public enum AppStateAccessor { + public static var register: (Any) -> Void = { _ in } +} + +// Apps declare their canonical state struct; codegen reads it and emits +// AppStateAccessor.register. The app's struct must be `@Observable` and +// must hold all snapshot-eligible state in `@Snapshotable`-marked fields. +@MainActor +public protocol AppState: AnyObject {} + #endif // DEBUG diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift index b8441df16..803bedf31 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift @@ -59,20 +59,18 @@ public final class StateServer { private var writeHandlers: [String: WriteHandler] = [:] private var typeNames: [String: TypeName] = [:] - // Validated-restore hooks. Every generated model registers one two-phase - // handler. The server validates all models before it lets any model mutate, - // so invalid input can never cause a cross-model partial restore. - // Valid restores apply properties on MainActor; observers may receive one - // change notification per property because arbitrary @Observable models do - // not expose a general single-assignment transaction API. - public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult + // Atomic-restore hook. Codegen wires this to the canonical AppState struct. + // Restore replaces the entire struct in one assignment so SwiftUI's Combine + // pipeline observes exactly one change notification — true observable + // atomicity. @MainActor alone doesn't guarantee that. + public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult public enum RestoreResult { case ok case missingKey(String) case typeMismatch(String) case schemaMismatch(expected: String, got: String) } - private var atomicRestores: [AtomicRestoreFn] = [] + private var atomicRestore: AtomicRestoreFn? // Snapshot schema hash — written by codegen, stable across builds with // identical accessor signatures. @@ -111,7 +109,7 @@ public final class StateServer { public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) { self.appBuildId = buildId self.accessorHash = accessorHash - self.atomicRestores.append(atomicRestore) + self.atomicRestore = atomicRestore } public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) { @@ -286,7 +284,6 @@ public final class StateServer { "version": "1.0.0", "build": appBuildId, "accessor_hash": accessorHash, - "bundle_id": Bundle.main.bundleIdentifier ?? "unknown", ]) return } @@ -479,36 +476,22 @@ public final class StateServer { send(connection: connection, status: 400, body: ["error": "missing_keys"]) return } - guard !atomicRestores.isEmpty else { + guard let restore = atomicRestore else { send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"]) return } - // Phase one validates every registered model without assignment. - for restore in atomicRestores { - switch restore(keys, false) { - case .ok: - continue - case .missingKey(let k): - send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"]) - return - case .typeMismatch(let k): - send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"]) - return - case .schemaMismatch(let expected, let got): - send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got]) - return - } + // Validate-then-apply via the codegen-supplied closure. The closure does + // a single struct-assignment so SwiftUI sees one change notification. + switch restore(keys) { + case .ok: + send(connection: connection, status: 200, body: ["ok": true]) + case .missingKey(let k): + send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"]) + case .typeMismatch(let k): + send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"]) + case .schemaMismatch(let expected, let got): + send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got]) } - - // Phase two applies only after every model accepted the immutable input. - // A valid multi-field restore may notify once per property. - for restore in atomicRestores { - guard case .ok = restore(keys, true) else { - send(connection: connection, status: 500, body: ["error": "restore_apply_failed"]) - return - } - } - send(connection: connection, status: 200, body: ["ok": true]) } // MARK: Stubs (real impls live in DebugBridgeManager + UIKit) @@ -539,19 +522,9 @@ public final class StateServer { // MARK: Response private func send(connection: NWConnection, status: Int, body: JSONDict) { - let responseStatus: Int - let json: Data - if JSONSerialization.isValidJSONObject(body), - let encoded = try? JSONSerialization.data(withJSONObject: body) { - responseStatus = status - json = encoded - } else { - logger.error("Refusing to send a non-JSON response body") - responseStatus = 500 - json = Data("{\"error\":\"response_not_json_serializable\"}".utf8) - } + let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8) let statusText: String - switch responseStatus { + switch status { case 200: statusText = "OK" case 400: statusText = "Bad Request" case 401: statusText = "Unauthorized" @@ -564,7 +537,7 @@ public final class StateServer { case 503: statusText = "Service Unavailable" default: statusText = "Status" } - let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n" + let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n" var packet = Data(header.utf8) packet.append(json) connection.send(content: packet, completion: .contentProcessed { _ in diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m index aa6954c6d..7f7b7d1a3 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m @@ -126,36 +126,6 @@ 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; @@ -202,7 +172,6 @@ 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 @@ -260,10 +229,6 @@ 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; @@ -282,17 +247,10 @@ 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:)]) { + if ([touch respondsToSelector:@selector(setGestureView:)] && + [hit isKindOfClass:[UIView class]]) { [touch setGestureView:(UIView *)hit]; } diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h index 86a2ddaa8..1f85c1211 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h @@ -23,10 +23,6 @@ NS_ASSUME_NONNULL_BEGIN @interface DebugBridgeTouch : NSObject -/// Enable the in-process accessibility automation tree used by SwiftUI and -/// UIKit. Call once while installing the debug bridge, before /elements. -+ (void)prepareForAutomation; - /// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given /// window-coordinate point. Returns YES if the touch was delivered (a hit /// view was found and the event passed through UIApplication.sendEvent). diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift index 4a91142de..bf7af6e3f 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift @@ -27,35 +27,12 @@ public enum DebugBridgeUIWiring { /// times reinstalls the same closures. Must be called on @MainActor /// because every UIKit access requires the main actor. public static func installAll() { - // KIF turns on accessibility automation before walking SwiftUI's AX - // tree. Without it SwiftUI exposes only the hosting shell and taps - // can report success without invoking Button.action. - DebugBridgeTouch.prepareForAutomation() ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() } ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() } MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) } } } -/// Return the children UIKit exposes specifically to accessibility automation. -/// iOS 17 added `automationElements`; unlike the older container APIs it -/// preserves identified SwiftUI descendants inside accessibility groups. -@MainActor -private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] { - if #available(iOS 17.0, *), - let automation = element.automationElements, - !automation.isEmpty { - return automation.compactMap { $0 as? NSObject } - } - if let accessibility = element.accessibilityElements, - !accessibility.isEmpty { - return accessibility.compactMap { $0 as? NSObject } - } - let count = element.accessibilityElementCount() - guard count > 0, count < 512 else { return [] } - return (0.. Data? { guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil } let bounds = window.bounds - let 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 renderer = UIGraphicsImageRenderer(bounds: bounds) let image = renderer.image { _ in // drawHierarchy is the documented way to snapshot real UIKit // layers including layer-backed views. afterScreenUpdates: false @@ -88,10 +61,7 @@ enum ScreenshotBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - 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 }) + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first } } @@ -106,40 +76,21 @@ enum ElementsBridgeImpl { static func snapshot() -> [JSONDict] { guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] } var elements: [JSONDict] = [] - var visited = Set() - var remaining = 2_048 - collect( - view: window, - parentPath: "", - window: window, - visited: &visited, - remaining: &remaining, - into: &elements - ) + collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements) return elements } - private static func collect( - view: UIView, - parentPath: String, - window: UIWindow, - visited: inout Set, - remaining: inout Int, - into elements: inout [JSONDict] - ) { - guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return } - remaining -= 1 - + private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) { // Skip hidden / zero-size / off-screen subtrees early. if view.isHidden || view.alpha < 0.01 { return } - let frameInWindow = view.convert(view.bounds, to: window) - if !window.bounds.intersects(frameInWindow) { return } + let frameInWindow = view.convert(view.bounds, to: nil) + if !windowBounds.intersects(frameInWindow) { return } let isAccessible = view.isAccessibilityElement let label = view.accessibilityLabel ?? "" let identifier = view.accessibilityIdentifier ?? "" - let traits = NSNumber(value: view.accessibilityTraits.rawValue) + let traits = Int(view.accessibilityTraits.rawValue) let value = (view.accessibilityValue ?? "") as String let className = String(describing: type(of: view)) let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)" @@ -169,98 +120,66 @@ enum ElementsBridgeImpl { ]) } - // Walk automation children before raw subviews. On iOS 17+ this - // exposes identified SwiftUI controls nested inside GroupBox/List. - for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() { - if let child = element as? UIView { - collect( - view: child, - parentPath: path, - window: window, - visited: &visited, - remaining: &remaining, - into: &elements - ) - } else { - appendSynthetic( - element, - path: "\(path) > ", - window: window, - visited: &visited, - remaining: &remaining, - into: &elements - ) + // Recurse into accessibility-elements first (some custom views vend + // synthetic children), then UIView subviews. SwiftUI's host views + // populate accessibilityElements lazily — many return nil before + // VoiceOver triggers them. Force population by reading accessibilityElementCount. + _ = view.accessibilityElementCount() + if let axElements = view.accessibilityElements { + for case let element as NSObject in axElements { + if let v = element as? UIView { + collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements) + } else { + // Synthetic accessibility element (no UIView). Capture frame in screen coords. + let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero + elements.append([ + "path": "\(path) > ", + "class": "AccessibilityElement", + "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", + "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", + "value": (element.value(forKey: "accessibilityValue") as? String) ?? "", + "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, + "frame": [ + "x": Int(af.origin.x), + "y": Int(af.origin.y), + "w": Int(af.size.width), + "h": Int(af.size.height), + ], + "is_user_interaction_enabled": true, + ]) + } + } + } else { + // accessibilityElements is nil — iterate by index. SwiftUI uses + // this dynamic protocol pattern; many AX elements only respond + // to accessibilityElementCount + accessibilityElement(at:). + let count = view.accessibilityElementCount() + for i in 0.. ", + "class": String(describing: type(of: element)), + "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", + "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", + "value": (element.value(forKey: "accessibilityValue") as? String) ?? "", + "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, + "frame": [ + "x": Int(af.origin.x), + "y": Int(af.origin.y), + "w": Int(af.size.width), + "h": Int(af.size.height), + ], + "is_user_interaction_enabled": true, + ]) + } } } for sub in view.subviews { - collect( - view: sub, - parentPath: path, - window: window, - visited: &visited, - remaining: &remaining, - into: &elements - ) - } - } - - private static func appendSynthetic( - _ element: NSObject, - path: String, - window: UIWindow, - visited: inout Set, - remaining: inout Int, - into elements: inout [JSONDict] - ) { - guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return } - remaining -= 1 - - let screenFrame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero - let frame = window.coordinateSpace.convert(screenFrame, from: window.screen.coordinateSpace) - let label = (element.value(forKey: "accessibilityLabel") as? String) ?? "" - let identifier = (element.value(forKey: "accessibilityIdentifier") as? String) ?? "" - let value = (element.value(forKey: "accessibilityValue") as? String) ?? "" - let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0 - if !label.isEmpty || !identifier.isEmpty || !value.isEmpty || traits != 0 { - elements.append([ - "path": path, - "class": String(describing: type(of: element)), - "label": label, - "identifier": identifier, - "value": value, - "traits": NSNumber(value: traits), - "frame": [ - "x": Int(frame.origin.x), - "y": Int(frame.origin.y), - "w": Int(frame.size.width), - "h": Int(frame.size.height), - ], - "is_user_interaction_enabled": true, - ]) - } - - // Synthetic SwiftUI nodes are themselves accessibility containers. - // Recurse even when this grouping node has no metadata of its own. - for (index, child) in debugBridgeAccessibilityChildren(of: element).enumerated() { - if let childView = child as? UIView { - collect( - view: childView, - parentPath: path, - window: window, - visited: &visited, - remaining: &remaining, - into: &elements - ) - } else { - appendSynthetic( - child, - path: "\(path) > ", - window: window, - visited: &visited, - remaining: &remaining, - into: &elements - ) - } + collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements) } } @@ -272,10 +191,7 @@ enum ElementsBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - 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 }) + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first } } @@ -294,62 +210,21 @@ enum MutationBridgeImpl { } } - /// 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. + /// 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. private static func handleTap(_ payload: JSONDict) -> Bool { guard let x = payload["x"] as? NSNumber, let y = payload["y"] as? NSNumber else { return false } let point = CGPoint(x: x.doubleValue, y: y.doubleValue) guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } - if let element = findActivatableAXElement(at: point, in: window), - element.accessibilityActivate() { - return true - } return DebugBridgeTouch.sendTap(at: point, in: window) } - private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? { - let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace) - var best: NSObject? - var bestArea: CGFloat = .infinity - var visited = Set() - var remaining = 2_048 - - func consider(frame: CGRect, traits: UInt64, element: NSObject) { - guard frame.contains(screenPoint), - (traits & UIAccessibilityTraits.button.rawValue) != 0 else { return } - let area = frame.width * frame.height - if area > 0 && area < bestArea { - best = element - bestArea = area - } - } - - func visit(_ element: NSObject) { - guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return } - remaining -= 1 - - if let view = element as? UIView { - guard !view.isHidden, view.alpha >= 0.01, - view.convert(view.bounds, to: window).contains(point) else { return } - if view.isAccessibilityElement { - consider(frame: view.accessibilityFrame, traits: view.accessibilityTraits.rawValue, element: view) - } - for child in debugBridgeAccessibilityChildren(of: view) { visit(child) } - for child in view.subviews { visit(child) } - } else { - let frame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero - let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0 - consider(frame: frame, traits: traits, element: element) - for child in debugBridgeAccessibilityChildren(of: element) { visit(child) } - } - } - - visit(window) - return best - } - /// Set text on the first responder if it's a UITextField or UITextView. private static func handleType(_ payload: JSONDict) -> Bool { guard let text = payload["text"] as? String else { return false } @@ -391,10 +266,7 @@ 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)) - // Automation commands return synchronously; do not report - // success while the target is still moving underneath the - // next tap coordinate. - scroll.setContentOffset(off, animated: false) + scroll.setContentOffset(off, animated: true) return true } node = cur.superview @@ -429,10 +301,7 @@ enum MutationBridgeImpl { } private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { - 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 }) + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first } } diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift index c96c92eaf..af18b72e3 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift @@ -1,21 +1,15 @@ -// 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. +// FixtureApp — minimal SwiftUI app used by the ios-qa device-path E2E test. // // On launch: -// 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 +// 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 // // 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 @@ -26,21 +20,14 @@ 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 are ready before the listener accepts requests. + // /tap, /type, /swipe actually do something on the device. #if canImport(UIKit) DebugBridgeUIWiring.installAll() #endif - DebugBridgeManager.shared.start( - appState: appState, - register: FixtureAppStateAccessor.register - ) #endif } @@ -52,707 +39,22 @@ struct FixtureAppApp: App { } struct ContentView: View { - 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 + @State private var counter: Int = 0 var body: some View { - 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) - } - .accessibilityIdentifier("fixture-tab-view") - .onChange(of: selectedTab) { newTab in - tabChangeCount += 1 - record("Selected \(newTab.title) tab") - } - } - - private var controlsTab: some View { - NavigationStack { - ScrollView { - VStack(spacing: 14) { - verificationPanel - - GroupBox { - LazyVGrid( - columns: [GridItem(.flexible()), GridItem(.flexible())], - spacing: 10 - ) { - Button { - primaryButtonCount += 1 - record("Primary button tapped") - } label: { - buttonLabel("Primary", count: primaryButtonCount, symbol: "bolt.fill") - } - .buttonStyle(.borderedProminent) - .accessibilityIdentifier("primary-button") - .accessibilityValue("\(primaryButtonCount) taps") - - Button { - borderedButtonCount += 1 - record("Bordered button tapped") - } label: { - buttonLabel("Bordered", count: borderedButtonCount, symbol: "square") - } - .buttonStyle(.bordered) - .accessibilityIdentifier("bordered-button") - .accessibilityValue("\(borderedButtonCount) taps") - - Button { - plainButtonCount += 1 - record("Plain button tapped") - } label: { - buttonLabel("Plain", count: plainButtonCount, symbol: "circle") - .padding(.vertical, 8) - .background(Color.accentColor.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - .accessibilityIdentifier("plain-button") - .accessibilityValue("\(plainButtonCount) taps") - - Button(role: .destructive) { - destructiveButtonCount += 1 - record("Destructive-style button tapped") - } label: { - buttonLabel( - "Destructive", - count: destructiveButtonCount, - symbol: "exclamationmark.triangle.fill" - ) - } - .buttonStyle(.bordered) - .accessibilityIdentifier("destructive-button") - .accessibilityValue("\(destructiveButtonCount) taps") - } - } label: { - Label("SwiftUI button styles", systemImage: "rectangle.3.group") - .font(.headline) - } - .accessibilityIdentifier("button-styles-group") - - GroupBox { - VStack(spacing: 10) { - verificationRow( - "Refresh", - value: toolbarRefreshCount, - identifier: "nav-refresh-count" - ) - verificationRow( - "Menu add", - value: menuAddCount, - identifier: "menu-add-count" - ) - verificationRow( - "Menu archive", - value: menuArchiveCount, - identifier: "menu-archive-count" - ) - verificationRow( - "Detail opened / closed", - textValue: "\(detailOpenCount) / \(detailCloseCount)", - identifier: "detail-navigation-count" - ) - } - } label: { - Label("Navigation and menu results", systemImage: "menubar.rectangle") - .font(.headline) - } - .accessibilityIdentifier("navigation-results-group") - - Button { - detailOpenCount += 1 - isShowingDetail = true - } label: { - Label("Open detail screen (\(detailOpenCount))", systemImage: "chevron.forward.circle.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(.indigo) - .accessibilityIdentifier("open-detail-button") - .accessibilityValue("Opened \(detailOpenCount) times") - } - .padding() - } - .accessibilityIdentifier("controls-scroll-view") - .navigationTitle("Tap Lab") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - toolbarRefreshCount += 1 - record("Navigation refresh tapped") - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .accessibilityIdentifier("nav-refresh-button") - .accessibilityValue("\(toolbarRefreshCount) refreshes") - } - - ToolbarItem(placement: .navigationBarTrailing) { - Menu { - Button { - menuAddCount += 1 - record("Add menu item selected") - } label: { - Label("Add item (\(menuAddCount))", systemImage: "plus") - } - .accessibilityIdentifier("menu-add-item") - - Button { - menuArchiveCount += 1 - record("Archive menu item selected") - } label: { - Label("Archive item (\(menuArchiveCount))", systemImage: "archivebox") - } - .accessibilityIdentifier("menu-archive-item") - } label: { - Image(systemName: "ellipsis.circle") - .accessibilityLabel("Harness actions menu") - } - .accessibilityIdentifier("toolbar-actions-menu") - .accessibilityValue("Add \(menuAddCount), archive \(menuArchiveCount)") - } - } - .navigationDestination(isPresented: $isShowingDetail) { - VStack(spacing: 18) { - verificationPanel - - Image(systemName: "rectangle.stack.badge.play.fill") - .font(.system(size: 46)) - .foregroundColor(.indigo) - .accessibilityIdentifier("detail-screen-artwork") - - Text("Navigation destination") - .font(.title2.bold()) - .accessibilityIdentifier("detail-screen-title") - - Button { - detailButtonCount += 1 - record("Detail button tapped") - } label: { - Label("Detail tap (\(detailButtonCount))", systemImage: "hand.tap") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .accessibilityIdentifier("detail-action-button") - .accessibilityValue("\(detailButtonCount) taps") - - Text("Detail count: \(detailButtonCount)") - .font(.headline.monospacedDigit()) - .accessibilityIdentifier("detail-action-count") - } - .padding() - .navigationTitle("Detail") - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - isShowingDetail = false - } label: { - Label("Back to Tap Lab", systemImage: "chevron.backward") - } - .accessibilityIdentifier("detail-back-button") - } - } - } - .onChange(of: isShowingDetail) { isShowing in - if isShowing { - record("Opened detail screen") - } else { - detailCloseCount += 1 - record("Closed detail screen") - } - } - } - .accessibilityIdentifier("controls-navigation-stack") - } - - private var inputsTab: some View { - NavigationStack { - ScrollView { - VStack(spacing: 14) { - verificationPanel - - GroupBox { - VStack(spacing: 14) { - Toggle(isOn: Binding( - get: { toggleValue }, - set: { newValue in - toggleValue = newValue - toggleChangeCount += 1 - record("Toggle changed to \(newValue ? "on" : "off")") - } - )) { - Label("Harness toggle", systemImage: toggleValue ? "checkmark.circle.fill" : "circle") - } - .accessibilityIdentifier("harness-toggle") - .accessibilityValue(toggleValue ? "On" : "Off") - - verificationRow( - "Toggle changes", - value: toggleChangeCount, - identifier: "toggle-change-count" - ) - - Divider() - - Stepper( - value: Binding( - get: { stepperValue }, - set: { newValue in - let direction = newValue > stepperValue ? "up" : "down" - stepperValue = newValue - stepperChangeCount += 1 - record("Stepper moved \(direction) to \(newValue)") - } - ), - in: 0...9 - ) { - Label("Stepper value: \(stepperValue)", systemImage: "plusminus.circle") - .monospacedDigit() - } - .accessibilityIdentifier("harness-stepper") - .accessibilityValue("Value \(stepperValue), changed \(stepperChangeCount) times") - - verificationRow( - "Stepper changes", - value: stepperChangeCount, - identifier: "stepper-change-count" - ) - verificationRow( - "Stepper value", - value: stepperValue, - identifier: "stepper-value" - ) - } - } label: { - Label("Toggle and stepper", systemImage: "switch.2") - .font(.headline) - } - .accessibilityIdentifier("toggle-stepper-group") - - GroupBox { - VStack(alignment: .leading, spacing: 12) { - Picker("Harness mode", selection: Binding( - get: { selectedMode }, - set: { newValue in - selectedMode = newValue - pickerChangeCount += 1 - record("Segment selected: \(newValue)") - } - )) { - Text("One").tag("One") - Text("Two").tag("Two") - Text("Three").tag("Three") - } - .pickerStyle(.segmented) - .accessibilityIdentifier("harness-segmented-picker") - .accessibilityValue("Selected \(selectedMode)") - - verificationRow( - "Selected segment", - textValue: selectedMode, - identifier: "picker-selection-value" - ) - verificationRow( - "Segment changes", - value: pickerChangeCount, - identifier: "picker-change-count" - ) - } - } label: { - Label("Segmented picker", systemImage: "rectangle.split.3x1") - .font(.headline) - } - .accessibilityIdentifier("picker-group") - - GroupBox { - VStack(alignment: .leading, spacing: 10) { - TextField("Type a device message", text: $draftText) - .focused($isTextFieldFocused) - .textFieldStyle(.roundedBorder) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.done) - .accessibilityIdentifier("harness-text-field") - .accessibilityValue(draftText) - .onSubmit { - commitText(source: "keyboard submit") - } - .onChange(of: draftText) { newValue in - textChangeCount += 1 - record("Text changed to \(newValue.isEmpty ? "empty" : newValue)") - } - - HStack { - Text("Echo: \(draftText.isEmpty ? "" : draftText)") - .font(.subheadline.monospaced()) - .lineLimit(1) - .accessibilityIdentifier("text-echo-value") - .accessibilityValue(draftText) - Spacer() - Button("Commit") { - commitText(source: "commit button") - } - .buttonStyle(.bordered) - .accessibilityIdentifier("commit-text-button") - .accessibilityValue("Committed \(textCommitCount) times") - } - - verificationRow( - "Text changes", - value: textChangeCount, - identifier: "text-change-count" - ) - verificationRow( - "Text commits", - value: textCommitCount, - identifier: "text-commit-count" - ) - } - } label: { - Label("Text entry", systemImage: "keyboard") - .font(.headline) - } - .accessibilityIdentifier("text-entry-group") - - #if canImport(UIKit) - GroupBox { - VStack(spacing: 10) { - UIKitHarnessButton(count: uikitButtonCount) { - uikitButtonCount += 1 - record("UIKit UIButton tapped") - } - .frame(maxWidth: .infinity, minHeight: 48) - - verificationRow( - "UIKit taps", - value: uikitButtonCount, - identifier: "uikit-button-count" - ) - } - } label: { - Label("Native UIKit control", systemImage: "iphone") - .font(.headline) - } - .accessibilityIdentifier("uikit-control-group") - #endif - } - .padding() - } - .accessibilityIdentifier("inputs-scroll-view") - .navigationTitle("Input Lab") - .navigationBarTitleDisplayMode(.inline) - } - .accessibilityIdentifier("inputs-navigation-stack") - } - - private var rowsTab: some View { - NavigationStack { - List { - Section { - verificationPanel - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - } - - Section("Tap a row") { - ForEach(Self.harnessRows) { row in - Button { - let count = rowTapCounts[row.id, default: 0] + 1 - rowTapCounts[row.id] = count - record("\(row.title) tapped") - } label: { - HStack(spacing: 12) { - Image(systemName: row.symbol) - .foregroundColor(.accentColor) - Text(row.title) - Spacer() - Text("\(rowTapCounts[row.id, default: 0])") - .font(.headline.monospacedDigit()) - .foregroundColor(.secondary) - .accessibilityIdentifier("row-\(row.id)-count") - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityIdentifier("row-\(row.id)-button") - .accessibilityValue("\(rowTapCounts[row.id, default: 0]) taps") - .swipeActions(edge: .leading, allowsFullSwipe: false) { - Button { - rowFlagCount += 1 - record("Flagged \(row.title)") - } label: { - Label("Flag", systemImage: "flag.fill") - } - .tint(.orange) - .accessibilityIdentifier("row-\(row.id)-flag-action") - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - rowArchiveCount += 1 - record("Archived \(row.title)") - } label: { - Label("Archive", systemImage: "archivebox.fill") - } - .tint(.indigo) - .accessibilityIdentifier("row-\(row.id)-archive-action") - } - } - } - - Section("Row action results") { - verificationRow( - "Flags", - value: rowFlagCount, - identifier: "row-flag-count" - ) - verificationRow( - "Archives", - value: rowArchiveCount, - identifier: "row-archive-count" - ) - verificationRow( - "Toolbar checks", - value: rowToolbarCount, - identifier: "row-toolbar-count" - ) - } - } - .listStyle(.insetGrouped) - .accessibilityIdentifier("rows-list") - .navigationTitle("Row Lab") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - rowToolbarCount += 1 - record("Rows toolbar check tapped") - } label: { - Label("Check rows", systemImage: "checkmark.circle") - } - .accessibilityIdentifier("rows-toolbar-button") - .accessibilityValue("\(rowToolbarCount) checks") - } - } - } - .accessibilityIdentifier("rows-navigation-stack") - } - - private var verificationPanel: some View { - VerificationPanel( - lastAction: lastAction, - totalActions: totalActions, - selectedTab: selectedTab.title, - tabChangeCount: tabChangeCount - ) - } - - private func buttonLabel(_ title: String, count: Int, symbol: String) -> some View { - VStack(spacing: 4) { - Label(title, systemImage: symbol) - .lineLimit(1) - Text("Count \(count)") - .font(.caption.monospacedDigit()) - .accessibilityIdentifier("\(title.lowercased())-button-count") - } - .frame(maxWidth: .infinity, minHeight: 40) - } - - private func verificationRow(_ label: String, value: Int, identifier: String) -> some View { - verificationRow(label, textValue: String(value), identifier: identifier) - } - - private func verificationRow(_ label: String, textValue: String, identifier: String) -> some View { - HStack { - Text(label) + VStack(spacing: 24) { + Text("ios-qa fixture") + .font(.largeTitle.bold()) + Text("StateServer should be on :9999") + .font(.subheadline) .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)") + Button("Tap (\(counter))") { + counter += 1 } - - 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) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("tap-button") } - .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") + .padding() + .accessibilityIdentifier("fixture-content") } } - -#if canImport(UIKit) -private struct UIKitHarnessButton: UIViewRepresentable { - let count: Int - let action: () -> Void - - final class Coordinator: NSObject { - var action: () -> Void - - init(action: @escaping () -> Void) { - self.action = action - } - - @objc func tapped() { - action() - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(action: action) - } - - func makeUIView(context: Context) -> UIButton { - let button = UIButton(type: .system) - var configuration = UIButton.Configuration.filled() - configuration.cornerStyle = .medium - configuration.image = UIImage(systemName: "hand.tap.fill") - configuration.imagePadding = 8 - button.configuration = configuration - button.accessibilityIdentifier = "uikit-button" - button.accessibilityLabel = "UIKit button" - button.addTarget(context.coordinator, action: #selector(Coordinator.tapped), for: .touchUpInside) - return button - } - - func updateUIView(_ button: UIButton, context: Context) { - context.coordinator.action = action - var configuration = button.configuration ?? UIButton.Configuration.filled() - configuration.title = "UIKit Tap (\(count))" - button.configuration = configuration - button.accessibilityValue = "\(count) taps" - } -} -#endif diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift index 13861ab03..2980cd9e7 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift @@ -1,22 +1,32 @@ -// Canonical observable app state for the fixture. Snapshot eligibility is a -// generator-only source marker, not a property wrapper, so it composes with -// Observation's @Observable macro. +// Canonical app state for the fixture. Every snapshot-eligible field is +// marked with the @Snapshotable property wrapper that the codegen tool +// detects via attribute scan. +// +// Note: we DON'T use @Observable here because the macro expansion converts +// stored properties into computed ones, which the @Snapshotable wrapper +// can't apply to. In production apps that need both observability AND +// snapshotting, the right pattern is: +// - Use ObservableObject + @Published (older API), or +// - Hold all @Snapshotable state in a nested struct + replace it +// wholesale on restore so SwiftUI sees a single change notification +// (the canonical-state-struct atomicity strategy from the plan). import Foundation -import Observation -@Observable -final class FixtureAppState { - // @Snapshotable - var isLoggedIn: Bool = false - // @Snapshotable - var username: String = "" - // @Snapshotable - var tapCounter: Int = 0 - // @Snapshotable - var nickname: String? = nil +public final class FixtureAppState { + @Snapshotable public var isLoggedIn: Bool = false + @Snapshotable public var username: String = "" + @Snapshotable public var tapCounter: Int = 0 /// Not snapshotted — ephemeral cache that should never leak via /state/snapshot. - var ephemeralCache: [String: String] = [:] + public var ephemeralCache: [String: String] = [:] - init() {} + public init() {} +} + +/// Property wrapper marker for snapshot-eligible state. The actual wrapper +/// is a no-op at runtime; codegen-tool detection happens via attribute scan. +@propertyWrapper +public struct Snapshotable { + public var wrappedValue: Value + public init(wrappedValue: Value) { self.wrappedValue = wrappedValue } } diff --git a/test/fixtures/ios-qa/FixtureApp/project.yml b/test/fixtures/ios-qa/FixtureApp/project.yml index c3c172883..35906f7b2 100644 --- a/test/fixtures/ios-qa/FixtureApp/project.yml +++ b/test/fixtures/ios-qa/FixtureApp/project.yml @@ -1,7 +1,7 @@ name: FixtureApp options: deploymentTarget: - iOS: "17.0" + iOS: "16.0" bundleIdPrefix: com.gstack.iosqa developmentLanguage: en createIntermediateGroups: true @@ -23,7 +23,7 @@ targets: FixtureApp: type: application platform: iOS - deploymentTarget: "17.0" + deploymentTarget: "16.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: "17.0" + IPHONEOS_DEPLOYMENT_TARGET: "16.0" ENABLE_PREVIEWS: YES diff --git a/test/ios-qa-regen.test.ts b/test/ios-qa-regen.test.ts deleted file mode 100644 index 0378869dd..000000000 --- a/test/ios-qa-regen.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { afterEach, describe, expect, test } from 'bun:test'; -import { createHash } from 'crypto'; -import { spawnSync } from 'child_process'; -import { - chmodSync, - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - statSync, - writeFileSync, -} from 'fs'; -import { tmpdir } from 'os'; -import { join, relative } from 'path'; - -const ROOT = join(import.meta.dir, '..'); -const SAFE_TEMPLATE_MAP = [ - ['Package.swift.template', 'Package.swift'], - ['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'], - ['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'], - ['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'], - ['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'], - ['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'], - ['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'], -] as const; - -const workDirs: string[] = []; - -afterEach(() => { - for (const dir of workDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -function copyIntoFakeInstall(workDir: string): { root: string; launcher: string } { - const root = join(workDir, 'fake gstack install'); - const binDir = join(root, 'bin'); - const scriptsDir = join(root, 'ios-qa', 'scripts'); - const templatesDir = join(root, 'ios-qa', 'templates'); - mkdirSync(binDir, { recursive: true }); - mkdirSync(scriptsDir, { recursive: true }); - mkdirSync(templatesDir, { recursive: true }); - - const launcher = join(binDir, 'gstack-ios-qa-regen'); - copyFileSync(join(ROOT, 'bin', 'gstack-ios-qa-regen'), launcher); - chmodSync(launcher, 0o755); - copyFileSync(join(ROOT, 'ios-qa', 'scripts', 'gen-accessors.ts'), join(scriptsDir, 'gen-accessors.ts')); - for (const [template] of SAFE_TEMPLATE_MAP) { - copyFileSync(join(ROOT, 'ios-qa', 'templates', template), join(templatesDir, template)); - } - - // These files deliberately exist in the discovered template directory. If - // the launcher ever regresses to wildcard copying, their sentinel content - // will escape into the app and fail the absence assertions below. - writeFileSync(join(templatesDir, 'DebugBridgeWiring.swift.template'), '// FORBIDDEN-WIRING-SENTINEL\n'); - writeFileSync(join(templatesDir, 'StateAccessor.swift.template'), '// FORBIDDEN-STATE-SENTINEL\n'); - writeFileSync(join(root, 'VERSION'), '9.8.7.6\n'); - return { root, launcher }; -} - -function treeHash(...roots: string[]): string { - const hash = createHash('sha256'); - for (const root of roots) { - const visit = (dir: string): void => { - for (const name of readdirSync(dir).sort()) { - const path = join(dir, name); - const stat = statSync(path); - if (stat.isDirectory()) { - visit(path); - } else { - hash.update(relative(root, path)); - hash.update('\0'); - hash.update(readFileSync(path)); - hash.update('\0'); - } - } - }; - visit(root); - } - return hash.digest('hex'); -} - -function allFileContents(root: string): string { - let contents = ''; - const visit = (dir: string): void => { - for (const name of readdirSync(dir)) { - const path = join(dir, name); - if (statSync(path).isDirectory()) visit(path); - else contents += readFileSync(path, 'utf8'); - } - }; - visit(root); - return contents; -} - -describe('gstack-ios-qa-regen', () => { - test('repository launcher is executable', () => { - expect(statSync(join(ROOT, 'bin', 'gstack-ios-qa-regen')).mode & 0o111).not.toBe(0); - }); - - test('requires the documented app-source and bridge-dir contract', () => { - const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-')); - workDirs.push(workDir); - const { launcher } = copyIntoFakeInstall(workDir); - const result = spawnSync('bash', [launcher, '--app-source', workDir], { encoding: 'utf8' }); - - expect(result.status).toBe(2); - expect(result.stderr).toContain('both --app-source and --bridge-dir are required'); - }); - - test('leaves no completion marker when accessor generation fails', () => { - const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-')); - workDirs.push(workDir); - const { launcher } = copyIntoFakeInstall(workDir); - const appSource = join(workDir, 'app-source'); - const bridgeDir = join(workDir, 'bridge'); - const generatedDir = join(appSource, 'DebugBridgeGenerated'); - const fakeBin = join(workDir, 'fake-bin'); - mkdirSync(generatedDir, { recursive: true }); - mkdirSync(fakeBin, { recursive: true }); - writeFileSync(join(appSource, 'AppState.swift'), '@Observable final class AppState {}\n'); - writeFileSync(join(generatedDir, '.gstack-version'), 'stale-complete-marker\n'); - const fakeBun = join(fakeBin, 'bun'); - writeFileSync(fakeBun, '#!/bin/sh\nexit 17\n'); - chmodSync(fakeBun, 0o755); - - const result = spawnSync('bash', [ - launcher, - '--app-source', appSource, - '--bridge-dir', bridgeDir, - ], { - encoding: 'utf8', - env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH ?? ''}` }, - }); - - expect(result.status).toBe(17); - expect(existsSync(join(generatedDir, '.gstack-version'))).toBe(false); - }); - - test('regenerates the allowlisted package and accessors idempotently', () => { - const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-')); - workDirs.push(workDir); - const { root, launcher } = copyIntoFakeInstall(workDir); - const appSource = join(workDir, 'app source'); - const bridgeDir = join(appSource, 'DebugBridge'); - const generatedDir = join(appSource, 'DebugBridgeGenerated'); - const cacheRoot = join(workDir, 'isolated cache'); - mkdirSync(appSource, { recursive: true }); - writeFileSync(join(appSource, 'AppState.swift'), ` -@Observable -final class AppState { - // @Snapshotable - var counter: Int = 0 -} -`); - - // Seed the flat legacy layout that old ios-sync versions produced. A - // correct regeneration must remove these known generated artifacts rather - // than letting Xcode compile a second, stale harness implementation. - mkdirSync(generatedDir, { recursive: true }); - for (const obsolete of [ - join(bridgeDir, 'DebugBridgeWiring.swift'), - join(bridgeDir, 'StateAccessor.swift'), - join(generatedDir, 'Package.swift'), - join(generatedDir, 'StateServer.swift'), - join(generatedDir, 'DebugBridgeManager.swift'), - join(generatedDir, 'Bridges.swift'), - join(generatedDir, 'DebugOverlay.swift'), - join(generatedDir, 'DebugBridgeTouch.m'), - join(generatedDir, 'DebugBridgeTouch.h'), - join(generatedDir, 'DebugBridgeWiring.swift'), - ]) { - mkdirSync(join(obsolete, '..'), { recursive: true }); - writeFileSync(obsolete, '// OBSOLETE-HARNESS-SENTINEL\n'); - } - - const env = { - ...process.env, - GSTACK_IOS_CACHE_ROOT: cacheRoot, - SWIFT_VERSION: '6.3.3', - GEN_ACCESSORS_REV: 'regen-test', - }; - const args = [launcher, '--app-source', appSource, '--bridge-dir', bridgeDir]; - const first = spawnSync('bash', args, { encoding: 'utf8', env }); - expect(first.status).toBe(0); - expect(first.stderr).toBe(''); - - // Every installed package file must be byte-identical to its explicit - // source template: this is the durable template/output parity contract. - for (const [template, destination] of SAFE_TEMPLATE_MAP) { - expect(readFileSync(join(bridgeDir, destination))).toEqual( - readFileSync(join(root, 'ios-qa', 'templates', template)), - ); - } - - const accessorPath = join(generatedDir, 'StateAccessor.swift'); - const accessor = readFileSync(accessorPath, 'utf8'); - expect(accessor).toContain('import DebugBridgeCore'); - expect(accessor).toContain('enum AppStateAccessor'); - expect(accessor).not.toContain('public enum AppStateAccessor'); - expect(accessor).not.toContain('import DebugBridge\n'); - expect(accessor).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)'); - expect(accessor).toContain('atomicRestore: { keys, apply in'); - expect(accessor).toContain('state.counter = restored0'); - expect(accessor).toContain('state.counter = typed'); - expect(accessor).toContain('return true'); - expect(accessor).not.toContain('atomicRestore: { _ in .ok }'); - expect(accessor).not.toContain('write: { _ in false }'); - expect(readFileSync(join(generatedDir, '.gstack-version'), 'utf8')).toBe( - readFileSync(join(root, 'VERSION'), 'utf8'), - ); - - expect(existsSync(join(bridgeDir, 'DebugBridgeWiring.swift'))).toBe(false); - expect(existsSync(join(bridgeDir, 'StateAccessor.swift'))).toBe(false); - for (const obsoleteName of [ - 'Package.swift', - 'StateServer.swift', - 'DebugBridgeManager.swift', - 'Bridges.swift', - 'DebugOverlay.swift', - 'DebugBridgeTouch.m', - 'DebugBridgeTouch.h', - 'DebugBridgeWiring.swift', - ]) { - expect(existsSync(join(generatedDir, obsoleteName))).toBe(false); - } - const installedContents = allFileContents(bridgeDir) + allFileContents(generatedDir); - expect(installedContents).not.toContain('FORBIDDEN-WIRING-SENTINEL'); - expect(installedContents).not.toContain('FORBIDDEN-STATE-SENTINEL'); - expect(installedContents).not.toContain('OBSOLETE-HARNESS-SENTINEL'); - - const swiftAvailable = spawnSync('swift', ['--version'], { encoding: 'utf8' }).status === 0; - if (swiftAvailable) { - const dump = spawnSync('swift', ['package', 'dump-package', '--package-path', bridgeDir], { - encoding: 'utf8', - }); - expect(dump.status).toBe(0); - const manifest = JSON.parse(dump.stdout) as { targets: Array<{ name: string }> }; - expect(manifest.targets.map(target => target.name).sort()).toEqual([ - 'DebugBridgeCore', - 'DebugBridgeTouch', - 'DebugBridgeUI', - ]); - } - - const firstHash = treeHash(bridgeDir, generatedDir); - const firstAccessorHash = accessor.match(/accessorHash: "([a-f0-9]+)"/)?.[1]; - const second = spawnSync('bash', args, { encoding: 'utf8', env }); - expect(second.status).toBe(0); - expect(second.stderr).toBe(''); - expect(second.stdout).toContain('gen-accessors: cache hit'); - expect(treeHash(bridgeDir, generatedDir)).toBe(firstHash); - expect(readFileSync(accessorPath, 'utf8').match(/accessorHash: "([a-f0-9]+)"/)?.[1]).toBe(firstAccessorHash); - }); -}); diff --git a/test/ios-qa-swiftui-tap-regression.test.ts b/test/ios-qa-swiftui-tap-regression.test.ts deleted file mode 100644 index 36aa7e924..000000000 --- a/test/ios-qa-swiftui-tap-regression.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -const ROOT = join(import.meta.dir, '..'); -const PRE_FIXTURE = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json'); -const PRE_SCREENSHOT = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png'); - -describe('ios-fix regression fixture — SwiftUI taps reported success without acting', () => { - test('preserves the pre-fix state and physical-device screenshot', () => { - const state = JSON.parse(readFileSync(PRE_FIXTURE, 'utf8')); - expect(state).toEqual({ - _schema_version: 1, - _app_build_id: 'uninitialized', - _accessor_hash: 'uninitialized', - keys: {}, - }); - - const png = readFileSync(PRE_SCREENSHOT); - expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); - expect(png.readUInt32BE(16)).toBe(1206); - expect(png.readUInt32BE(20)).toBe(2622); - }); - - test('keeps the physical-device deploy/tap test opt-in and executable', () => { - const deviceTest = readFileSync(join(ROOT, 'test/skill-e2e-ios-device.test.ts'), 'utf8'); - expect(deviceTest).toContain("process.env.GSTACK_IOS_DEVICE_DEPLOY === '1'"); - expect(deviceTest).toContain("'primary-button'"); - expect(deviceTest).toContain("'/tap'"); - expect(deviceTest).not.toContain("test.skip('TODO(deploy)"); - }); -}); diff --git a/test/skill-e2e-ios-device.test.ts b/test/skill-e2e-ios-device.test.ts index 200cb6557..1517be80c 100644 --- a/test/skill-e2e-ios-device.test.ts +++ b/test/skill-e2e-ios-device.test.ts @@ -1,8 +1,4 @@ -// 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: +// GSTACK_HAS_IOS_DEVICE=1 device-path test. 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) @@ -14,91 +10,57 @@ // 4. The fixture iOS SPM package builds with `swift build` for iOS target // (verifies the templates compile against the iOS SDK, not just macOS) // -// 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. +// 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. import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; -import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'fs'; -import { tmpdir } from 'os'; import { join } from 'path'; -import { startDaemon, type RunningDaemon } from '../ios-qa/daemon/src/index'; -import { startTunnelKeepalive } from '../ios-qa/daemon/src/devicectl'; -import { bootstrapTunnel } from '../ios-qa/daemon/src/tunnel-bootstrap'; -import type { DeviceTunnel } from '../ios-qa/daemon/src/proxy'; const ROOT = join(import.meta.dir, '..'); const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp'); const HAS_DEVICE = process.env.GSTACK_HAS_IOS_DEVICE === '1'; -const DEPLOY_TO_DEVICE = process.env.GSTACK_IOS_DEVICE_DEPLOY === '1'; const describeIfDevice = HAS_DEVICE ? describe : describe.skip; -const testIfDeploy = DEPLOY_TO_DEVICE ? test : test.skip; interface DeviceListEntry { identifier: string; state: string; // "available" | "available (pairing)" | "unavailable" | ... name: string; model: string; - platform: string; - transport: string; - paired: boolean; -} - -interface DeviceElement { - identifier?: string; - label?: string; - value?: string; - frame?: { x: number; y: number; w: number; h: number }; -} - -interface StateSnapshot { - _app_build_id?: string; - _accessor_hash?: string; - keys?: Record; } function listDevices(): DeviceListEntry[] { // devicectl JSON output requires --json-output to a path. Use a tempfile. const tmp = `/tmp/devicectl-list-${process.pid}-${Date.now()}.json`; + const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], { + stdio: 'pipe', + timeout: 30_000, + }); + if (r.status !== 0) return []; try { - const 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 fs = require('fs'); + const raw = fs.readFileSync(tmp, 'utf-8'); const obj = JSON.parse(raw); - return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string; pairingState?: string; transportType?: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string; platform?: string } }) => ({ + fs.unlinkSync(tmp); + return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string }; deviceProperties: { name: string }; hardwareProperties: { productType: 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`; @@ -107,146 +69,12 @@ function isPaired(udid: string): boolean { '-d', udid, '--json-output', tmp, ], { stdio: 'pipe', timeout: 30_000 }); - try { unlinkSync(tmp); } catch { /* ignore */ } + try { require('fs').unlinkSync(tmp); } catch { /* ignore */ } // Pair-required errors surface on stderr with "must be paired" or // CoreDeviceError 2. Treat any non-zero exit as not-paired. return r.status === 0; } -function requireDeployEnv(name: 'GSTACK_IOS_DEVELOPMENT_TEAM' | 'GSTACK_IOS_BUNDLE_ID'): string { - const value = process.env[name]?.trim(); - if (!value) { - throw new Error(`${name} is required when GSTACK_IOS_DEVICE_DEPLOY=1`); - } - return value; -} - -function runChecked( - command: string, - args: string[], - opts: { cwd?: string; timeout?: number } = {}, -): string { - const result = spawnSync(command, args, { - cwd: opts.cwd, - env: process.env, - stdio: 'pipe', - timeout: opts.timeout ?? 60_000, - maxBuffer: 32 * 1024 * 1024, - }); - const output = `${result.stdout?.toString() ?? ''}${result.stderr?.toString() ?? ''}`; - if (result.error || result.status !== 0) { - const tail = output.split('\n').slice(-120).join('\n'); - throw new Error([ - `${command} ${args.join(' ')} failed (${result.error?.message ?? `exit ${result.status}`})`, - tail, - ].filter(Boolean).join('\n')); - } - return output; -} - -async function daemonJson( - baseURL: string, - path: string, - init: RequestInit = {}, -): Promise<{ status: number; body: T; raw: string }> { - const response = await fetch(`${baseURL}${path}`, { - ...init, - signal: AbortSignal.timeout(60_000), - }); - const raw = await response.text(); - let body: T; - try { - body = JSON.parse(raw) as T; - } catch { - throw new Error(`${init.method ?? 'GET'} ${path} returned non-JSON HTTP ${response.status}: ${raw.slice(0, 500)}`); - } - return { status: response.status, body, raw }; -} - -function findElement(elements: DeviceElement[], identifier: string): DeviceElement | undefined { - return elements.find((element) => - element.identifier === identifier - && (element.frame?.w ?? 0) > 0 - && (element.frame?.h ?? 0) > 0, - ); -} - -type DeviceElementPredicate = (element: DeviceElement) => boolean; - -interface DeviceViewport { - w: number; - h: number; -} - -function isInsideViewport(element: DeviceElement, viewport: DeviceViewport): boolean { - const frame = element.frame; - if (!frame || frame.w <= 0 || frame.h <= 0) return false; - const centerX = frame.x + frame.w / 2; - const centerY = frame.y + frame.h / 2; - return centerX >= 0 && centerX <= viewport.w && centerY >= 0 && centerY <= viewport.h; -} - -async function readDeviceElements(baseURL: string): Promise { - const result = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements'); - if (result.status !== 200 || !Array.isArray(result.body.elements)) { - throw new Error(`GET /elements failed with HTTP ${result.status}: ${result.raw.slice(0, 500)}`); - } - return result.body.elements; -} - -async function waitForDeviceElement( - baseURL: string, - predicate: DeviceElementPredicate, - description: string, - options: { - condition?: DeviceElementPredicate; - tappableIn?: DeviceViewport; - timeoutMs?: number; - } = {}, -): Promise { - const deadline = Date.now() + (options.timeoutMs ?? 10_000); - let lastElements: DeviceElement[] = []; - while (Date.now() < deadline) { - lastElements = await readDeviceElements(baseURL); - const match = lastElements.find((element) => - predicate(element) - && (options.condition?.(element) ?? true) - && (!options.tappableIn || isInsideViewport(element, options.tappableIn)), - ); - if (match) return match; - await new Promise((resolve) => setTimeout(resolve, 200)); - } - const visible = lastElements - .filter((element) => element.identifier || element.label) - .slice(0, 80) - .map((element) => element.identifier ?? element.label) - .join(', '); - throw new Error(`timed out waiting for ${description}; last elements: ${visible}`); -} - -async function tapDeviceElement( - baseURL: string, - sessionId: string, - element: DeviceElement, -): Promise { - const frame = element.frame; - if (!frame) throw new Error('cannot tap an element without a frame'); - const tapped = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/tap', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-session-id': sessionId, - }, - body: JSON.stringify({ - x: frame.x + frame.w / 2, - y: frame.y + frame.h / 2, - }), - }); - if (tapped.status !== 200 || tapped.body.ok !== true) { - throw new Error(`tap failed with HTTP ${tapped.status}: ${tapped.raw.slice(0, 500)}`); - } -} - describeIfDevice('ios device path', () => { test('devicectl lists at least one connected device', () => { const devices = listDevices(); @@ -276,9 +104,11 @@ describeIfDevice('ios device path', () => { expect(paired.length).toBeGreaterThan(0); }); - 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. + 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. const sdkPath = spawnSync('xcrun', ['--sdk', 'iphoneos', '--show-sdk-path'], { stdio: 'pipe' }); if (sdkPath.status !== 0) { console.error('iOS SDK not found. Install via Xcode.'); @@ -287,8 +117,16 @@ describeIfDevice('ios device path', () => { const sdk = sdkPath.stdout.toString().trim(); expect(sdk).toContain('iPhoneOS'); - // SwiftPM cannot directly cross-build this UIKit package with the standalone - // host command, so keep the static guard assertion honest and narrowly named. + // 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. const fs = require('fs') as typeof import('fs'); const overlay = fs.readFileSync( join(FIXTURE_PATH, 'Sources/DebugBridgeUI/DebugOverlay.swift'), @@ -299,575 +137,26 @@ describeIfDevice('ios device path', () => { expect(overlay).toContain('#endif'); }); -}); - -describe('ios device deployment (explicit opt-in)', () => { - testIfDeploy('generates, signs, installs, launches, and drives the fixture through the daemon', async () => { - const developmentTeam = requireDeployEnv('GSTACK_IOS_DEVELOPMENT_TEAM'); - const bundleId = requireDeployEnv('GSTACK_IOS_BUNDLE_ID'); - const devices = listDevices(); - const device = devices.find((candidate) => isAvailableIPhone(candidate) && isPaired(candidate.identifier)); - if (!device) { - const summary = devices.length > 0 - ? devices.map((d) => ` ${d.name} (${d.model}, ${d.platform}, ${d.identifier}): state=${d.state}, paired=${d.paired}`).join('\n') - : ' devicectl returned no devices'; - throw new Error([ - 'GSTACK_IOS_DEVICE_DEPLOY=1 requires an available, paired iPhone; stale unavailable devices are never selected.', - summary, - ].join('\n')); - } - - const workDir = mkdtempSync(join(tmpdir(), 'gstack-ios-device-deploy-')); - const fixtureDir = join(workDir, 'FixtureApp'); - const derivedData = join(workDir, 'DerivedData'); - let daemon: RunningDaemon | undefined; - let keepalive: { stop: () => void } | undefined; - let sessionId: string | undefined; - - try { - cpSync(FIXTURE_PATH, fixtureDir, { recursive: true }); - - // Exercise the same deterministic bootstrap that /ios-qa and /ios-sync - // install for users. This creates the app-owned typed accessor before - // XcodeGen discovers the fixture sources. - runChecked(join(ROOT, 'bin/gstack-ios-qa-regen'), [ - '--app-source', join(fixtureDir, 'Sources/FixtureApp'), - '--bridge-dir', fixtureDir, - ], { cwd: fixtureDir }); - const generatedAccessor = join( - fixtureDir, - 'Sources/FixtureApp/DebugBridgeGenerated/StateAccessor.swift', - ); - expect(existsSync(generatedAccessor)).toBe(true); - expect(readFileSync(generatedAccessor, 'utf8')).toContain('enum FixtureAppStateAccessor'); - - runChecked('xcodegen', [ - 'generate', - '--spec', join(fixtureDir, 'project.yml'), - '--project', fixtureDir, - '--project-root', fixtureDir, - ], { cwd: fixtureDir }); - - const projectPath = join(fixtureDir, 'FixtureApp.xcodeproj'); - expect(existsSync(projectPath)).toBe(true); - - runChecked('xcodebuild', [ - '-project', projectPath, - '-scheme', 'FixtureApp', - '-configuration', 'Debug', - '-destination', `platform=iOS,id=${device.identifier}`, - '-derivedDataPath', derivedData, - '-allowProvisioningUpdates', - `DEVELOPMENT_TEAM=${developmentTeam}`, - `PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`, - 'CODE_SIGN_STYLE=Automatic', - 'build', - ], { cwd: fixtureDir, timeout: 300_000 }); - - const appBundle = join(derivedData, 'Build/Products/Debug-iphoneos/FixtureApp.app'); - expect(existsSync(appBundle)).toBe(true); - const builtBundleId = runChecked('/usr/libexec/PlistBuddy', [ - '-c', 'Print :CFBundleIdentifier', - join(appBundle, 'Info.plist'), - ]).trim(); - expect(builtBundleId).toBe(bundleId); - - runChecked('xcrun', [ - 'devicectl', 'device', 'install', 'app', - '--device', device.identifier, - appBundle, - ], { timeout: 120_000 }); - - runChecked('xcrun', [ - 'devicectl', 'device', 'process', 'launch', - '--device', device.identifier, - '--terminate-existing', - bundleId, - ], { timeout: 60_000 }); - - keepalive = startTunnelKeepalive(device.identifier); - const bootstrap = await bootstrapTunnel({ - udid: device.identifier, - bundleId, - startupTimeoutMs: 30_000, - }); - if (!bootstrap.ok) { - throw new Error(`daemon tunnel bootstrap failed: ${bootstrap.error}${bootstrap.detail ? ` (${bootstrap.detail})` : ''}`); - } - - // The first provider call consumes the already-rotated bootstrap. Later - // calls perform a fresh bootstrap so the same daemon can recover after - // this app is relaunched and its in-memory bearer changes. - let pendingTunnel: DeviceTunnel | undefined = bootstrap.tunnel; - let tunnelProviderCalls = 0; - const provideTunnel = async (): Promise => { - tunnelProviderCalls += 1; - if (pendingTunnel) { - const first = pendingTunnel; - pendingTunnel = undefined; - return first; - } - const refreshed = await bootstrapTunnel({ - udid: device.identifier, - bundleId, - startupTimeoutMs: 30_000, - }); - if (!refreshed.ok) { - throw new Error(`daemon rebootstrap failed: ${refreshed.error}${refreshed.detail ? ` (${refreshed.detail})` : ''}`); - } - return refreshed.tunnel; - }; - - const started = await startDaemon({ - loopbackPort: 0, - tailnetEnabled: false, - pidfilePath: join(workDir, 'daemon.pid'), - tunnelProvider: provideTunnel, - }); - if ('error' in started) { - throw new Error(`daemon failed to start: ${started.error}${started.reason ? ` (${started.reason})` : ''}`); - } - daemon = started; - const baseURL = `http://127.0.0.1:${daemon.loopbackPort}`; - - const initialState = await daemonJson(baseURL, '/state/snapshot'); - expect(initialState.status).toBe(200); - expect(typeof initialState.body._app_build_id).toBe('string'); - expect(initialState.body._app_build_id).not.toBe('unknown'); - expect(initialState.body._app_build_id).not.toBe('uninitialized'); - expect(initialState.body._accessor_hash).toMatch(/^[a-f0-9]{64}$/); - expect(initialState.body._accessor_hash).not.toBe('uninitialized'); - expect(Object.keys(initialState.body.keys ?? {}).sort()).toEqual([ - 'isLoggedIn', - 'nickname', - 'tapCounter', - 'username', - ]); - expect(initialState.body.keys?.nickname).toBeNull(); - - const screenshot = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot'); - expect(screenshot.status).toBe(200); - expect(typeof screenshot.body.png_base64).toBe('string'); - const png = Buffer.from(screenshot.body.png_base64!, 'base64'); - expect(png.length).toBeGreaterThan(1_000); - expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); - - const requiredIdentifiers = [ - 'primary-button', - 'toolbar-actions-menu', - 'open-detail-button', - 'tab-controls', - 'tab-inputs', - 'tab-rows', - ]; - let elementsBefore: DeviceElement[] = []; - let identifiers = new Set(); - const elementsDeadline = Date.now() + 10_000; - while (Date.now() < elementsDeadline) { - const before = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements'); - expect(before.status).toBe(200); - expect(Array.isArray(before.body.elements)).toBe(true); - elementsBefore = before.body.elements ?? []; - identifiers = new Set( - elementsBefore.map((element) => element.identifier).filter((value): value is string => Boolean(value)), - ); - if (requiredIdentifiers.every((identifier) => identifiers.has(identifier))) break; - await new Promise((resolve) => setTimeout(resolve, 250)); - } - expect(elementsBefore.length).toBeGreaterThan(30); - expect(requiredIdentifiers.filter((identifier) => !identifiers.has(identifier))).toEqual([]); - const appFrame = findElement(elementsBefore, 'fixture-tab-view')?.frame; - expect(appFrame).toBeDefined(); - // Screenshot pixels and /tap coordinates must share UIKit's point - // space. A 3x PNG here recreates the original missed-tap bug. - expect(png.readUInt32BE(16)).toBe(appFrame!.w); - expect(png.readUInt32BE(20)).toBe(appFrame!.h); - - const buttonBefore = findElement(elementsBefore, 'primary-button'); - expect(buttonBefore).toBeDefined(); - expect(typeof buttonBefore!.value).toBe('string'); - - const acquired = await daemonJson<{ session_id?: string }>(baseURL, '/session/acquire', { method: 'POST' }); - expect(acquired.status).toBe(200); - expect(typeof acquired.body.session_id).toBe('string'); - sessionId = acquired.body.session_id!; - - const rejectedBooleanAsInteger = await daemonJson<{ error?: string }>(baseURL, '/state/tapCounter', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-session-id': sessionId, - }, - body: JSON.stringify({ value: true }), - }); - expect(rejectedBooleanAsInteger.status).toBe(400); - expect(rejectedBooleanAsInteger.body.error).toBe('type_mismatch'); - - const rejectedIntegerAsBoolean = await daemonJson<{ error?: string }>(baseURL, '/state/isLoggedIn', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-session-id': sessionId, - }, - body: JSON.stringify({ value: 1 }), - }); - expect(rejectedIntegerAsBoolean.status).toBe(400); - expect(rejectedIntegerAsBoolean.body.error).toBe('type_mismatch'); - const afterRejectedCoercions = await daemonJson(baseURL, '/state/snapshot'); - expect(afterRejectedCoercions.body.keys?.tapCounter).toBe(0); - expect(afterRejectedCoercions.body.keys?.isLoggedIn).toBe(false); - - const wroteState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/tapCounter', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-session-id': sessionId, - }, - body: JSON.stringify({ value: 7 }), - }); - expect(wroteState.status).toBe(200); - expect(wroteState.body).toEqual({ ok: true }); - const updatedState = await daemonJson(baseURL, '/state/snapshot'); - expect(updatedState.status).toBe(200); - expect(updatedState.body.keys?.tapCounter).toBe(7); - - const wroteOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-session-id': sessionId, - }, - body: JSON.stringify({ value: 'Device' }), - }); - expect(wroteOptional.status).toBe(200); - const optionalValue = await daemonJson(baseURL, '/state/snapshot'); - expect(optionalValue.body.keys?.nickname).toBe('Device'); - - const clearedOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-session-id': sessionId, - }, - body: JSON.stringify({ value: null }), - }); - expect(clearedOptional.status).toBe(200); - const clearedValue = await daemonJson(baseURL, '/state/snapshot'); - expect(clearedValue.body.keys?.nickname).toBeNull(); - - const restoredState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/restore', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-session-id': sessionId, - }, - body: JSON.stringify(initialState.body), - }); - expect(restoredState.status).toBe(200); - expect(restoredState.body).toEqual({ ok: true }); - const afterRestore = await daemonJson(baseURL, '/state/snapshot'); - expect(afterRestore.body.keys?.tapCounter).toBe(0); - expect(afterRestore.body.keys?.nickname).toBeNull(); - - const viewport = { w: appFrame!.w, h: appFrame!.h }; - const byIdentifier = (identifier: string): DeviceElementPredicate => - (element) => element.identifier === identifier; - const byLabel = (label: string): DeviceElementPredicate => - (element) => element.label?.trim() === label; - - const tapAndWaitForValueChange = async ( - target: DeviceElementPredicate, - oracle: DeviceElementPredicate, - description: string, - ): Promise => { - const before = await waitForDeviceElement( - baseURL, - oracle, - `${description} oracle before tap`, - { condition: (element) => typeof element.value === 'string' }, - ); - const targetElement = await waitForDeviceElement( - baseURL, - target, - `${description} target`, - { tappableIn: viewport }, - ); - await tapDeviceElement(baseURL, sessionId!, targetElement); - const after = await waitForDeviceElement( - baseURL, - oracle, - `${description} value change`, - { condition: (element) => typeof element.value === 'string' && element.value !== before.value }, - ); - expect(after.value).not.toBe(before.value); - return after; - }; - - const firstInteger = (value: string | undefined): number | undefined => { - const match = value?.match(/-?\d+/); - return match ? Number(match[0]) : undefined; - }; - - const tapAndWaitForCountIncrement = async ( - target: DeviceElementPredicate, - oracle: DeviceElementPredicate, - description: string, - ): Promise => { - const before = await waitForDeviceElement( - baseURL, - oracle, - `${description} counter before tap`, - { condition: (element) => firstInteger(element.value) !== undefined }, - ); - const beforeCount = firstInteger(before.value)!; - const targetElement = await waitForDeviceElement( - baseURL, - target, - `${description} target`, - { tappableIn: viewport }, - ); - await tapDeviceElement(baseURL, sessionId!, targetElement); - const after = await waitForDeviceElement( - baseURL, - oracle, - `${description} exactly-once counter increment`, - { condition: (element) => firstInteger(element.value) === beforeCount + 1 }, - ); - expect(firstInteger(after.value)).toBe(beforeCount + 1); - return after; - }; - - // SwiftUI button styles and both navigation-bar controls. - for (const identifier of [ - 'primary-button', - 'bordered-button', - 'plain-button', - 'destructive-button', - 'nav-refresh-button', - ]) { - await tapAndWaitForCountIncrement( - byIdentifier(identifier), - byIdentifier(identifier), - identifier, - ); - } - - // Menu presentation and both menu commands. The menu's own value is the - // stable oracle after each transient command element disappears. - for (const commandIdentifier of ['menu-add-item', 'menu-archive-item']) { - const menuBefore = await waitForDeviceElement( - baseURL, - byIdentifier('toolbar-actions-menu'), - 'toolbar menu value', - { condition: (element) => typeof element.value === 'string' }, - ); - const menu = await waitForDeviceElement( - baseURL, - byIdentifier('toolbar-actions-menu'), - 'toolbar actions menu', - { tappableIn: viewport }, - ); - await tapDeviceElement(baseURL, sessionId, menu); - const command = await waitForDeviceElement( - baseURL, - byIdentifier(commandIdentifier), - commandIdentifier, - { tappableIn: viewport }, - ); - await tapDeviceElement(baseURL, sessionId, command); - const beforeCounts = [...(menuBefore.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0])); - expect(beforeCounts).toHaveLength(2); - const changedIndex = commandIdentifier === 'menu-add-item' ? 0 : 1; - const expectedCounts = beforeCounts.map((count, index) => count + (index === changedIndex ? 1 : 0)); - const menuAfter = await waitForDeviceElement( - baseURL, - byIdentifier('toolbar-actions-menu'), - `${commandIdentifier} exactly-once result`, - { - condition: (element) => { - const counts = [...(element.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0])); - return counts.length === 2 && counts.every((count, index) => count === expectedCounts[index]); - }, - }, - ); - const afterCounts = [...(menuAfter.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0])); - expect(afterCounts).toEqual(expectedCounts); - } - - // Push, interact with, and pop the explicit navigation destination. - const openDetail = await waitForDeviceElement( - baseURL, - byIdentifier('open-detail-button'), - 'open detail button', - { tappableIn: viewport }, - ); - await tapDeviceElement(baseURL, sessionId, openDetail); - await waitForDeviceElement(baseURL, byIdentifier('detail-screen-title'), 'detail destination'); - await tapAndWaitForCountIncrement( - byIdentifier('detail-action-button'), - byIdentifier('detail-action-button'), - 'detail action button', - ); - const back = await waitForDeviceElement( - baseURL, - byIdentifier('detail-back-button'), - 'detail back button', - { tappableIn: viewport }, - ); - await tapDeviceElement(baseURL, sessionId, back); - await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'controls after detail pop'); - - // Tab navigation plus native toggle, stepper, segmented picker, text - // input/commit, and a UIKit UIButton. - const inputsTab = await waitForDeviceElement( - baseURL, - byIdentifier('tab-inputs'), - 'Inputs tab', - { tappableIn: viewport }, - ); - await tapDeviceElement(baseURL, sessionId, inputsTab); - await waitForDeviceElement(baseURL, byIdentifier('harness-toggle'), 'Inputs controls'); - await tapAndWaitForValueChange( - byIdentifier('harness-toggle'), - byIdentifier('harness-toggle'), - 'toggle', - ); - await tapAndWaitForCountIncrement( - byIdentifier('harness-stepper-Increment'), - byIdentifier('harness-stepper'), - 'stepper increment', - ); - const segmentTwo = await waitForDeviceElement( - baseURL, - byLabel('Two'), - 'segmented picker option Two', - { tappableIn: viewport }, - ); - await tapDeviceElement(baseURL, sessionId, segmentTwo); - const selectedTwo = await waitForDeviceElement( - baseURL, - byIdentifier('harness-segmented-picker'), - 'segmented picker selection', - { condition: (element) => element.value?.includes('Two') === true }, - ); - expect(selectedTwo.value).toContain('Two'); - - const textField = await waitForDeviceElement( - baseURL, - byIdentifier('harness-text-field'), - 'text field', - { tappableIn: viewport }, - ); - await tapDeviceElement(baseURL, sessionId, textField); - const typed = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/type', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-session-id': sessionId, - }, - body: JSON.stringify({ text: 'device matrix' }), - }); - expect(typed.status).toBe(200); - expect(typed.body).toMatchObject({ op: 'type', ok: true }); - await waitForDeviceElement( - baseURL, - byIdentifier('harness-text-field'), - 'typed text value', - { condition: (element) => element.value === 'device matrix' }, - ); - await tapAndWaitForCountIncrement( - byIdentifier('commit-text-button'), - byIdentifier('commit-text-button'), - 'text commit button', - ); - // Commit clears FocusState; let the keyboard dismissal animation finish - // before choosing a scroll-view hit point for the UIKit control below. - await new Promise((resolve) => setTimeout(resolve, 500)); - - const scrollInputs = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/swipe', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-session-id': sessionId, - }, - body: JSON.stringify({ from_x: 200, from_y: 650, to_x: 200, to_y: 220 }), - }); - expect(scrollInputs.status).toBe(200); - expect(scrollInputs.body).toMatchObject({ op: 'swipe', ok: true }); - await tapAndWaitForCountIncrement( - byIdentifier('uikit-button'), - byIdentifier('uikit-button'), - 'UIKit button', - ); - - // All four list rows and the row navigation-bar action. - const rowsTab = await waitForDeviceElement( - baseURL, - byIdentifier('tab-rows'), - 'Rows tab', - { tappableIn: viewport }, - ); - await tapDeviceElement(baseURL, sessionId, rowsTab); - await waitForDeviceElement(baseURL, byIdentifier('row-alpha-button'), 'Rows list'); - for (const row of ['alpha', 'bravo', 'charlie', 'delta']) { - await tapAndWaitForCountIncrement( - byIdentifier(`row-${row}-button`), - byIdentifier(`row-${row}-button`), - `${row} row`, - ); - } - await tapAndWaitForCountIncrement( - byIdentifier('rows-toolbar-button'), - byIdentifier('rows-toolbar-button'), - 'rows toolbar button', - ); - - const controlsTab = await waitForDeviceElement( - baseURL, - byIdentifier('tab-controls'), - 'Controls tab', - { tappableIn: viewport }, - ); - await tapDeviceElement(baseURL, sessionId, controlsTab); - await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'Controls tab restored'); - - // Keep the daemon alive while the app process gets a new boot token. - // The first proxied request must observe the stale bearer, invalidate - // only that tunnel, single-flight a fresh bootstrap, and retry once. - runChecked('xcrun', [ - 'devicectl', 'device', 'process', 'launch', - '--device', device.identifier, - '--terminate-existing', - bundleId, - ], { timeout: 60_000 }); - await new Promise((resolve) => setTimeout(resolve, 500)); - const afterRelaunch = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot'); - expect(afterRelaunch.status).toBe(200); - expect(Buffer.from(afterRelaunch.body.png_base64 ?? '', 'base64').length).toBeGreaterThan(1_000); - expect(tunnelProviderCalls).toBe(2); - const stateAfterRelaunch = await daemonJson(baseURL, '/state/snapshot'); - expect(stateAfterRelaunch.status).toBe(200); - expect(stateAfterRelaunch.body._accessor_hash).toBe(initialState.body._accessor_hash); - } finally { - if (daemon && sessionId) { - await daemonJson(`http://127.0.0.1:${daemon.loopbackPort}`, '/session/release', { method: 'POST' }).catch(() => undefined); - } - if (daemon) await daemon.close(); - keepalive?.stop(); - rmSync(workDir, { recursive: true, force: true }); - } - }, 600_000); + // Documented next step. Becomes a real test once we have: + // - test/fixtures/ios-qa/FixtureApp/FixtureApp.xcodeproj (or generated) + // - A signing certificate + provisioning profile on the test machine + // - GSTACK_IOS_DEVICE_DEPLOY=1 environment opt-in + // + // The flow would be: + // xcodebuild -scheme FixtureApp -destination 'platform=iOS,id=' \ + // -allowProvisioningUpdates build install + // xcrun devicectl device process launch -d --console + // # Scrape boot token from os_log + // curl http://[]:9999/healthz + // # ... full smoke loop ... + test.skip('TODO(deploy): build + deploy fixture to device + smoke test full StateServer loop', () => {}); }); // 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 => - d.platform.toLowerCase() === 'ios' - && d.model.toLowerCase().startsWith('iphone') - && !d.paired, - ); + const unpaired = devices.filter(d => !isPaired(d.identifier)); if (unpaired.length > 0) { console.error(''); console.error('=== iOS DEVICE PAIRING REQUIRED ==='); diff --git a/test/skill-e2e-ios-swift-build.test.ts b/test/skill-e2e-ios-swift-build.test.ts index 253b4cb84..8a8c3b92b 100644 --- a/test/skill-e2e-ios-swift-build.test.ts +++ b/test/skill-e2e-ios-swift-build.test.ts @@ -19,90 +19,38 @@ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; -import { readFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; const ROOT = join(import.meta.dir, '..'); const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp'); const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates'); -const GEN_ACCESSORS_PACKAGE = join(ROOT, 'ios-qa/scripts/gen-accessors-tool/Package.swift'); -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; - -function readTemplate(name: string): string { - return readFileSync(join(TEMPLATES_PATH, name), 'utf-8'); -} - -function normalizeBridgePackage(source: string): string { - // Package.swift.template has a generated-file prologue while the fixture has - // a fixture-specific one. The tools-version declaration must remain first in - // both real files, but neither header is part of the copied bridge surface. - const importOffset = source.indexOf('import PackageDescription'); - expect(importOffset).toBeGreaterThanOrEqual(0); - let packageBody = source.slice(importOffset); - - // The fixture deliberately has its own package identity and XCTest target. - // Normalize only those fixture concerns; all three bridge products, targets, - // dependencies, settings, and paths must otherwise stay in lockstep. - packageBody = packageBody.replace( - /(let package = Package\(\s*name:)\s*"[^"]+"/, - '$1 ""', - ); - packageBody = packageBody.replace( - /\n\s*\.testTarget\(\s*\n\s*name:\s*"DebugBridgeCoreTests",[\s\S]*?\n\s{8}\),?/, - '', - ); - - // Ignore prose and formatting so a template-only explanatory comment does - // not conceal a meaningful manifest mismatch. - return packageBody - .replace(/\/\/.*$/gm, '') - .replace(/\s+/g, '') - .replace(/,([\])])/g, '$1'); -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function bracedBlock(source: string, openBraceOffset: number): string { - let depth = 0; - for (let offset = openBraceOffset; offset < source.length; offset++) { - if (source[offset] === '{') depth++; - if (source[offset] !== '}') continue; - depth--; - if (depth === 0) return source.slice(openBraceOffset, offset + 1); - } - return ''; -} - -// The fixture is where the bridge is compiled and exercised end-to-end. Every -// source copied into consuming apps must therefore be the canonical template, -// or device QA can pass against code that /ios-qa never installs. +// 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', () => { - for (const [templateName, fixtureDestination] of COPIED_BRIDGE_TEMPLATES) { - test(`${templateName} matches ${fixtureDestination}`, () => { - expect(readTemplate(templateName)).toBe( - readFileSync(join(FIXTURE_PATH, fixtureDestination), 'utf-8'), - ); - }); - } + test('DebugBridgeTouch.h.template matches fixture include', () => { + const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'); + const fixture = readFileSync( + join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), + 'utf-8', + ); + expect(tmpl).toBe(fixture); + }); - test('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('DebugBridgeTouch.m.template matches fixture .m', () => { + const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); + const fixture = readFileSync( + join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), + 'utf-8', + ); + expect(tmpl).toBe(fixture); }); test('Package.swift.template declares all 3 DebugBridge targets', () => { - const tmpl = readTemplate('Package.swift.template'); + const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); // Each target must be present as a library product AND a target definition. for (const name of ['DebugBridgeCore', 'DebugBridgeUI', 'DebugBridgeTouch']) { expect(tmpl).toContain(`name: "${name}"`); @@ -111,190 +59,6 @@ describe('template ↔ fixture parity', () => { // app gets the transitive set with one dependency entry. expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/); }); - - test('generated Swift packages only reference shipped test directories', () => { - const genAccessorsPackage = readFileSync(GEN_ACCESSORS_PACKAGE, 'utf-8'); - const debugBridgePackage = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); - - expect(genAccessorsPackage).not.toContain('Tests/GenAccessorsTests'); - expect(debugBridgePackage).not.toContain('Tests/DebugBridgeCoreTests'); - }); - - test('Package.swift.template keeps swift-tools-version on the first line', () => { - const tmpl = readTemplate('Package.swift.template'); - expect(tmpl.split(/\r?\n/, 1)[0]).toBe('// swift-tools-version:5.9'); - }); -}); - -describe('iOS tap harness regressions', () => { - test('manager receives app-owned generated accessors instead of a no-op package stub', () => { - const manager = readTemplate('DebugBridgeManager.swift.template'); - const wiring = readTemplate('DebugBridgeWiring.swift.template'); - const fixtureApp = readFileSync( - join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppApp.swift'), - 'utf-8', - ); - - expect(manager).toContain('func start'); - expect(manager).toContain('register: (State) -> Void'); - expect(manager).toContain('register(appState)'); - expect(manager).not.toContain('public enum AppStateAccessor'); - expect(manager).not.toContain('protocol AppState'); - - expect(wiring).toContain('import DebugBridgeCore'); - expect(wiring).toContain('import DebugBridgeUI'); - expect(wiring).toContain('DebugBridgeUIWiring.installAll()'); - expect(wiring.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan( - wiring.indexOf('DebugBridgeManager.shared.start'), - ); - expect(fixtureApp.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan( - fixtureApp.indexOf('DebugBridgeManager.shared.start'), - ); - expect(wiring).not.toContain('import DebugBridge\n'); - expect(wiring).not.toContain('AccessibilityScanner'); - expect(wiring).not.toContain('MutationDispatcher'); - }); - - test('fixture uses an @Observable-compatible source marker, not a property wrapper', () => { - const state = readFileSync( - join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppState.swift'), - 'utf-8', - ); - expect(state).toContain('@Observable'); - expect(state.match(/\/\/ @Snapshotable/g)?.length).toBe(4); - expect(state).not.toContain('@propertyWrapper'); - expect(state).not.toMatch(/^[\t ]*@Snapshotable[\t ]+(?:public[\t ]+)?var/m); - }); - - test('recurses through iOS automation elements to expose nested SwiftUI controls', () => { - const bridges = readTemplate('Bridges.swift.template'); - expect(bridges).toContain('element.automationElements'); - expect(bridges).toContain('debugBridgeAccessibilityChildren(of: element)'); - expect(bridges).toContain('visited.insert(ObjectIdentifier(element)).inserted'); - expect(bridges).toContain('var remaining = 2_048'); - }); - - test('enables accessibility automation before SwiftUI AX is installed', () => { - const implementation = readTemplate('DebugBridgeTouch.m.template'); - const bridges = readTemplate('Bridges.swift.template'); - const helper = [...implementation.matchAll( - /static\s+void\s+([A-Za-z_]\w*)\s*\(\s*void\s*\)\s*\{/g, - )].find((candidate) => { - const body = bracedBlock( - implementation, - candidate.index! + candidate[0].lastIndexOf('{'), - ); - return body.includes('_AXSAutomationEnabled') && body.includes('_AXSSetAutomationEnabled'); - }); - expect(helper).toBeDefined(); - - const helperName = helper![1]; - const helperBody = bracedBlock( - implementation, - helper!.index! + helper![0].lastIndexOf('{'), - ); - expect(helperBody).toContain('_AXSAutomationEnabled'); - expect(helperBody).toContain('_AXSSetAutomationEnabled'); - - // Accept either Objective-C's eager +load hook or an explicit public - // bootstrap selector, but require the enabling helper to be called before - // Swift installs the resolver that walks SwiftUI's accessibility tree. - const bootstrap = [...implementation.matchAll(/\+\s*\(void\)\s*([A-Za-z_]\w*)\s*\{/g)] - .find((candidate) => bracedBlock( - implementation, - candidate.index! + candidate[0].lastIndexOf('{'), - ).match(new RegExp(`\\b${escapeRegExp(helperName)}\\s*\\(`))); - expect(bootstrap).toBeDefined(); - - if (bootstrap![1] === 'load') { - expect(bootstrap!.index!).toBeLessThan(implementation.indexOf('+ (BOOL)sendTapAtPoint:')); - } else { - const bootstrapCall = bridges.search( - new RegExp(`DebugBridgeTouch\\.${escapeRegExp(bootstrap![1])}\\s*\\(`), - ); - expect(bootstrapCall).toBeGreaterThanOrEqual(0); - expect(bootstrapCall).toBeLessThan(bridges.indexOf('ElementsBridge.resolver')); - } - }); - - test('renders screenshots at one pixel per window point', () => { - const bridges = readTemplate('Bridges.swift.template'); - const declaration = bridges.match( - /(?:let|var)\s+([A-Za-z_]\w*)\s*=\s*UIGraphicsImageRendererFormat(?:\.default)?\(\)/, - ); - expect(declaration).not.toBeNull(); - - const formatName = declaration![1]; - const scalePattern = new RegExp(`\\b${escapeRegExp(formatName)}\\.scale\\s*=\\s*1(?:\\.0)?\\b`); - const rendererPattern = new RegExp( - `UIGraphicsImageRenderer\\(\\s*bounds:\\s*bounds,\\s*format:\\s*${escapeRegExp(formatName)}\\s*\\)`, - ); - const declarationOffset = declaration!.index!; - const scaleOffset = bridges.search(scalePattern); - const rendererOffset = bridges.search(rendererPattern); - - expect(scaleOffset).toBeGreaterThan(declarationOffset); - expect(rendererOffset).toBeGreaterThan(scaleOffset); - }); - - test('uses accessibilityActivate for SwiftUI while retaining synthesized-touch delivery', () => { - const bridges = readTemplate('Bridges.swift.template'); - const tapStart = bridges.indexOf('private static func handleTap'); - const tapEnd = bridges.indexOf('private static func handleType', tapStart); - expect(tapStart).toBeGreaterThanOrEqual(0); - expect(tapEnd).toBeGreaterThan(tapStart); - const handleTap = bridges.slice(tapStart, tapEnd); - - const synthesizedTouchOffset = handleTap.indexOf('DebugBridgeTouch.sendTap'); - const fallbackCall = handleTap.match( - /\b([A-Za-z_]\w*)\s*\(\s*at:\s*point\s*,\s*in:\s*window\s*\)/, - ); - const activationOffset = handleTap.indexOf('.accessibilityActivate()'); - expect(synthesizedTouchOffset).toBeGreaterThanOrEqual(0); - expect(fallbackCall).not.toBeNull(); - expect(activationOffset).toBeGreaterThan(fallbackCall!.index!); - - const fallbackName = fallbackCall![1]; - expect(bridges).toMatch( - new RegExp(`(?:private\\s+)?static\\s+func\\s+${escapeRegExp(fallbackName)}\\s*\\(`), - ); - }); - - test('finishes programmatic scrolls before returning success to the next tap', () => { - const bridges = readTemplate('Bridges.swift.template'); - expect(bridges).toContain('setContentOffset(off, animated: false)'); - expect(bridges).not.toContain('setContentOffset(off, animated: true)'); - }); - - test('serializes accessibility traits without signed Int truncation', () => { - const bridges = readTemplate('Bridges.swift.template'); - expect(bridges).toMatch(/\btraits\s*:\s*UInt64\b/); - expect(bridges).toContain('.uint64Value'); - expect(bridges).not.toMatch(/\bInt(?:64)?\s*\(\s*view\.accessibilityTraits\.rawValue\s*\)/); - expect(bridges).not.toMatch(/accessibilityTraits[\s\S]{0,120}?\.intValue\b/); - }); - - test('validates every generated model before applying any snapshot state', () => { - const server = readTemplate('StateServer.swift.template'); - const validationLoop = server.indexOf('for restore in atomicRestores'); - const applyComment = server.indexOf('Phase two applies only after every model accepted'); - const applyLoop = server.indexOf('for restore in atomicRestores', validationLoop + 1); - - expect(server).toContain('typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult'); - expect(server).toContain('restore(keys, false)'); - expect(server).toContain('restore(keys, true)'); - expect(validationLoop).toBeGreaterThanOrEqual(0); - expect(applyComment).toBeGreaterThan(validationLoop); - expect(applyLoop).toBeGreaterThan(applyComment); - }); - - test('turns non-JSON response bodies into an explicit HTTP 500', () => { - const server = readTemplate('StateServer.swift.template'); - expect(server).toContain('JSONSerialization.isValidJSONObject(body)'); - expect(server).toContain('responseStatus = 500'); - expect(server).toContain('response_not_json_serializable'); - expect(server).not.toContain('?? Data("{}".utf8)'); - }); }); function hasSwift(): boolean { diff --git a/test/skill-e2e-ios.test.ts b/test/skill-e2e-ios.test.ts index 56211637a..8d8f09c56 100644 --- a/test/skill-e2e-ios.test.ts +++ b/test/skill-e2e-ios.test.ts @@ -171,12 +171,9 @@ 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] = [:] } `); @@ -192,8 +189,7 @@ 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('enum AppStateAccessor'); - expect(generatedSwift).not.toContain('public enum AppStateAccessor'); + expect(generatedSwift).toContain('public enum AppStateAccessor'); expect(generatedSwift).toContain('key: "isLoggedIn"'); expect(generatedSwift).toContain('key: "counter"'); expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable