From 83b1bd535541fe010bb6d10aad9348b88612f68d Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 18:38:11 -0700 Subject: [PATCH 1/5] revert(test): remove oversized iOS fixture expansion --- .../ios-fix/ios-qa-swiftui-tap-pre.json | 6 - .../ios-fix/ios-qa-swiftui-tap-pre.png | Bin 97916 -> 0 bytes .../DebugBridgeCore/DebugBridgeManager.swift | 29 +- .../Sources/DebugBridgeCore/StateServer.swift | 71 +- .../DebugBridgeTouch/DebugBridgeTouch.m | 46 +- .../include/DebugBridgeTouch.h | 4 - .../Sources/DebugBridgeUI/Bridges.swift | 279 ++---- .../Sources/FixtureApp/FixtureAppApp.swift | 734 +--------------- .../Sources/FixtureApp/FixtureAppState.swift | 42 +- test/fixtures/ios-qa/FixtureApp/project.yml | 6 +- test/ios-qa-regen.test.ts | 258 ------ test/ios-qa-swiftui-tap-regression.test.ts | 32 - test/skill-e2e-ios-device.test.ts | 809 ++---------------- test/skill-e2e-ios-swift-build.test.ts | 278 +----- test/skill-e2e-ios.test.ts | 12 +- 15 files changed, 239 insertions(+), 2367 deletions(-) delete mode 100644 test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json delete mode 100644 test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png delete mode 100644 test/ios-qa-regen.test.ts delete mode 100644 test/ios-qa-swiftui-tap-regression.test.ts 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 c5f22e90ece360e7f5967e50e145d06534021830..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 97916 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: 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 From 630e3d3a28a10362ce7163059b99d39e62a541d2 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 18:38:33 -0700 Subject: [PATCH 2/5] fix(careful): parse hook commands as JSON first --- careful/bin/check-careful.sh | 27 +++++++++++++++++++-------- test/hook-scripts.test.ts | 28 +++++++++++++++------------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/careful/bin/check-careful.sh b/careful/bin/check-careful.sh index 22bf8b922..adde07510 100755 --- a/careful/bin/check-careful.sh +++ b/careful/bin/check-careful.sh @@ -7,13 +7,24 @@ set -euo pipefail # Read stdin (JSON with tool_input) INPUT=$(cat) -# Extract the "command" field value from tool_input -# Try grep/sed first (handles 99% of cases), fall back to Python for escaped quotes -CMD=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//;s/"$//' || true) - -# Python fallback if grep returned empty (e.g., escaped quotes in command) -if [ -z "$CMD" ]; then - CMD=$(printf '%s' "$INPUT" | python3 -c 'import sys,json; print(json.loads(sys.stdin.read()).get("tool_input",{}).get("command",""))' 2>/dev/null || true) +# Parse JSON before inspecting the command. Regex extraction cannot distinguish +# escaped quotes from the end of a JSON string and can hide a destructive tail. +# A malformed hook payload is itself unsafe, so fail closed and ask. +if ! CMD=$(printf '%s' "$INPUT" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +if not isinstance(data, dict): + raise ValueError("hook payload must be an object") +tool_input = data.get("tool_input", {}) +if not isinstance(tool_input, dict): + raise ValueError("tool_input must be an object") +command = tool_input.get("command", "") +if not isinstance(command, str): + raise ValueError("command must be a string") +sys.stdout.write(command) +' 2>/dev/null); then + echo '{"permissionDecision":"ask","message":"[careful] Could not parse the command payload safely. Review it before proceeding."}' + exit 0 fi # If we still couldn't extract a command, allow @@ -30,7 +41,7 @@ CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]') # syntax or comments can hide an earlier destructive command, for example: # rm -rf / # rm -rf node_modules # Unknown syntax fails closed and falls through to the destructive checks. -if printf '%s' "$CMD" | grep -qE '^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$' 2>/dev/null; then +if [[ "$CMD" != *$'\n'* ]] && printf '%s' "$CMD" | grep -qE '^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$' 2>/dev/null; then echo '{}' exit 0 fi diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index db2e7629f..8402139f5 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -114,11 +114,6 @@ describe('check-careful.sh', () => { }); // --- SQL destructive commands --- - // Note: SQL commands that contain embedded double quotes (e.g., psql -c "DROP TABLE") - // get their command value truncated by the grep-based JSON extractor because \" - // terminates the [^"]* match. We use commands WITHOUT embedded quotes so the grep - // extraction works and the SQL keywords are visible to the pattern matcher. - describe('SQL destructive commands', () => { test('psql DROP TABLE warns with DROP in message', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('psql -c DROP TABLE users;')); @@ -140,6 +135,16 @@ describe('check-careful.sh', () => { expect(output.permissionDecision).toBe('ask'); expect(output.message).toContain('TRUNCATE'); }); + + test('escaped quotes cannot hide DROP TABLE from JSON parsing', () => { + const { exitCode, output } = runHook( + CAREFUL_SCRIPT, + carefulInput('psql -c "DROP TABLE users"'), + ); + expect(exitCode).toBe(0); + expect(output.permissionDecision).toBe('ask'); + expect(output.message).toContain('DROP'); + }); }); // --- Git destructive commands --- @@ -241,17 +246,14 @@ describe('check-careful.sh', () => { expect(output.permissionDecision).toBeUndefined(); }); - test('malformed JSON input allows gracefully (exit 0, output {})', () => { - const { exitCode, raw } = runHookRaw(CAREFUL_SCRIPT, 'this is not json at all{{{{'); + test('malformed JSON input fails closed', () => { + const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, 'this is not json at all{{{{'); expect(exitCode).toBe(0); - expect(raw).toBe('{}'); + expect(output.permissionDecision).toBe('ask'); + expect(output.message).toContain('parse'); }); - test('Python fallback: grep fails on multiline JSON, Python parses it', () => { - // Construct JSON where "command": and the value are on separate lines. - // grep works line-by-line, so it cannot match "command"..."value" across lines. - // This forces CMD to be empty, triggering the Python fallback which handles - // the full JSON correctly. + test('JSON parser handles a command value on the next line', () => { const rawJson = '{"tool_input":{"command":\n"rm -rf /tmp/important"}}'; const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, rawJson); expect(exitCode).toBe(0); From b8491d2d18065d18b9dcae92a13d7d32be812225 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 18:40:45 -0700 Subject: [PATCH 3/5] fix(ios-qa): keep private touch APIs out of release apps --- ios-qa/templates/DebugBridgeTouch.h.template | 2 +- ios-qa/templates/DebugBridgeTouch.m.template | 11 ++- ios-qa/templates/Package.swift.template | 10 ++- test/fixtures/ios-qa/FixtureApp/Package.swift | 5 +- .../DebugBridgeTouch/DebugBridgeTouch.m | 11 ++- .../include/DebugBridgeTouch.h | 2 +- test/skill-e2e-ios-swift-build.test.ts | 77 +++++++++++++------ 7 files changed, 85 insertions(+), 33 deletions(-) diff --git a/ios-qa/templates/DebugBridgeTouch.h.template b/ios-qa/templates/DebugBridgeTouch.h.template index 86a2ddaa8..7106791bf 100644 --- a/ios-qa/templates/DebugBridgeTouch.h.template +++ b/ios-qa/templates/DebugBridgeTouch.h.template @@ -1,7 +1,7 @@ // // DebugBridgeTouch.h — public Objective-C interface for in-process touch // synthesis. Implementation derived from KIF (https://github.com/kif-framework/KIF), -// MIT-licensed. The minimal subset needed to deliver a real UITouch to a +// Apache-2.0 licensed. The minimal subset needed to deliver a real UITouch to a // point on the key window, including SwiftUI Buttons via iOS 18+ // _UIHitTestContext. DEBUG-only — never link in Release. diff --git a/ios-qa/templates/DebugBridgeTouch.m.template b/ios-qa/templates/DebugBridgeTouch.m.template index aa6954c6d..2c664f08a 100644 --- a/ios-qa/templates/DebugBridgeTouch.m.template +++ b/ios-qa/templates/DebugBridgeTouch.m.template @@ -1,7 +1,10 @@ // // DebugBridgeTouch.m — minimal port of KIF's in-process touch synthesis. -// Original code: https://github.com/kif-framework/KIF — MIT-licensed -// (Square, Inc. + KIF contributors). Adapted to a single-file, tap-only, +// Original code: https://github.com/kif-framework/KIF — Apache-2.0 +// Copyright 2011-2016 Square, Inc. and KIF contributors. +// Licensed under the Apache License, Version 2.0. You may obtain a copy at +// https://www.apache.org/licenses/LICENSE-2.0 +// Adapted to a single-file, tap-only, // iOS 18+ aware subset for the gstack/ios-qa DebugBridge. // // Uses these private UIKit selectors (DEBUG-only; never shipped to App Store): @@ -20,6 +23,8 @@ #import "DebugBridgeTouch.h" #import +#if DEBUG + #if TARGET_OS_IOS #import @@ -341,3 +346,5 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { @end #endif // TARGET_OS_IOS + +#endif // DEBUG diff --git a/ios-qa/templates/Package.swift.template b/ios-qa/templates/Package.swift.template index 427a3399d..9b2e2f65a 100644 --- a/ios-qa/templates/Package.swift.template +++ b/ios-qa/templates/Package.swift.template @@ -12,9 +12,10 @@ // MutationBridge implementations. Depends on the other // two. // -// The structural Release-build guard is the `.when(configuration: .debug)` -// conditional on every consuming target's dependency. SwiftPM refuses to link -// DebugBridge* in Release config. +// The structural Release-build guard is compile-time: every Swift source is +// wrapped in `#if DEBUG`, and the Objective-C touch target receives the same +// DEBUG define so its private-API implementation compiles to an empty object +// in Release. // // CI invariant: `swift build -c release` + `nm -j build/Release/ // | grep -q DebugBridge && exit 1`. @@ -43,6 +44,9 @@ let package = Package( dependencies: [], path: "Sources/DebugBridgeTouch", publicHeadersPath: "include", + cSettings: [ + .define("DEBUG", .when(configuration: .debug)), + ], linkerSettings: [ // IOKit is loaded dynamically via dlopen at runtime (it's a // private framework on iOS and can't be linked statically). diff --git a/test/fixtures/ios-qa/FixtureApp/Package.swift b/test/fixtures/ios-qa/FixtureApp/Package.swift index 40477f0fe..660b0b6cf 100644 --- a/test/fixtures/ios-qa/FixtureApp/Package.swift +++ b/test/fixtures/ios-qa/FixtureApp/Package.swift @@ -3,7 +3,7 @@ // DebugBridgeCore (Foundation+Network) builds cross-platform. // DebugBridgeUI (UIKit/SwiftUI) is iOS-only. // DebugBridgeTouch (Objective-C) is iOS-only — in-process tap synthesis -// derived from KIF (MIT). DEBUG-only. +// derived from KIF (Apache-2.0). DEBUG-only. import PackageDescription @@ -32,6 +32,9 @@ let package = Package( dependencies: [], path: "Sources/DebugBridgeTouch", publicHeadersPath: "include", + cSettings: [ + .define("DEBUG", .when(configuration: .debug)), + ], linkerSettings: [ .linkedFramework("UIKit", .when(platforms: [.iOS])), ] diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m index 7f7b7d1a3..a8b413d85 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m @@ -1,7 +1,10 @@ // // DebugBridgeTouch.m — minimal port of KIF's in-process touch synthesis. -// Original code: https://github.com/kif-framework/KIF — MIT-licensed -// (Square, Inc. + KIF contributors). Adapted to a single-file, tap-only, +// Original code: https://github.com/kif-framework/KIF — Apache-2.0 +// Copyright 2011-2016 Square, Inc. and KIF contributors. +// Licensed under the Apache License, Version 2.0. You may obtain a copy at +// https://www.apache.org/licenses/LICENSE-2.0 +// Adapted to a single-file, tap-only, // iOS 18+ aware subset for the gstack/ios-qa DebugBridge. // // Uses these private UIKit selectors (DEBUG-only; never shipped to App Store): @@ -20,6 +23,8 @@ #import "DebugBridgeTouch.h" #import +#if DEBUG + #if TARGET_OS_IOS #import @@ -299,3 +304,5 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { @end #endif // TARGET_OS_IOS + +#endif // DEBUG diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h index 1f85c1211..e44034530 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h @@ -1,7 +1,7 @@ // // DebugBridgeTouch.h — public Objective-C interface for in-process touch // synthesis. Implementation derived from KIF (https://github.com/kif-framework/KIF), -// MIT-licensed. The minimal subset needed to deliver a real UITouch to a +// Apache-2.0 licensed. The minimal subset needed to deliver a real UITouch to a // point on the key window, including SwiftUI Buttons via iOS 18+ // _UIHitTestContext. DEBUG-only — never link in Release. diff --git a/test/skill-e2e-ios-swift-build.test.ts b/test/skill-e2e-ios-swift-build.test.ts index 8a8c3b92b..d6e4bf0e1 100644 --- a/test/skill-e2e-ios-swift-build.test.ts +++ b/test/skill-e2e-ios-swift-build.test.ts @@ -19,36 +19,15 @@ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; -import { existsSync, readFileSync } from 'fs'; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; import { join } from 'path'; const ROOT = join(import.meta.dir, '..'); const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp'); const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates'); -// Parity: canonical Obj-C touch templates must match the fixture's working -// copy. The fixture is the only place the .m / .h are exercised end-to-end -// on a real device, so any divergence means consuming apps would ship a -// stale, untested version of the SwiftUI hit-test fix. describe('template ↔ fixture parity', () => { - test('DebugBridgeTouch.h.template matches fixture include', () => { - const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'); - const fixture = readFileSync( - join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), - 'utf-8', - ); - expect(tmpl).toBe(fixture); - }); - - test('DebugBridgeTouch.m.template matches fixture .m', () => { - const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); - const fixture = readFileSync( - join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), - 'utf-8', - ); - expect(tmpl).toBe(fixture); - }); - test('Package.swift.template declares all 3 DebugBridge targets', () => { const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); // Each target must be present as a library product AND a target definition. @@ -59,6 +38,13 @@ 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('KIF-derived touch source preserves Apache-2.0 attribution', () => { + const implementation = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); + expect(implementation).toContain('Copyright 2011-2016 Square, Inc. and KIF contributors.'); + expect(implementation).toContain('Apache License, Version 2.0'); + expect(implementation).not.toContain('MIT-licensed'); + }); }); function hasSwift(): boolean { @@ -151,4 +137,49 @@ describeIfSwift('swift build invariants', () => { } expect(foundForbidden).toBe(0); }, 300_000); + + test('final Release iOS app excludes private touch APIs', () => { + if (spawnSync('xcrun', ['--find', 'xcodebuild']).status !== 0 || + spawnSync('xcodegen', ['--version']).status !== 0) return; + + const workDir = mkdtempSync(join(tmpdir(), 'gstack-ios-release-guard-')); + const fixtureDir = join(workDir, 'FixtureApp'); + try { + cpSync(FIXTURE_PATH, fixtureDir, { recursive: true }); + writeFileSync( + join(fixtureDir, 'Package.swift'), + readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'), + ); + writeFileSync( + join(fixtureDir, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), + readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'), + ); + writeFileSync( + join(fixtureDir, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), + readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'), + ); + expect(spawnSync('xcodegen', [ + 'generate', '--spec', join(fixtureDir, 'project.yml'), + '--project', fixtureDir, '--project-root', fixtureDir, + ], { cwd: fixtureDir, stdio: 'pipe' }).status).toBe(0); + + const derivedData = join(workDir, 'DerivedData'); + const build = spawnSync('xcodebuild', [ + '-project', join(fixtureDir, 'FixtureApp.xcodeproj'), + '-scheme', 'FixtureApp', '-configuration', 'Release', + '-sdk', 'iphonesimulator', '-destination', 'generic/platform=iOS Simulator', + '-derivedDataPath', derivedData, 'CODE_SIGNING_ALLOWED=NO', 'build', + ], { cwd: fixtureDir, stdio: 'pipe', timeout: 300_000 }); + expect(build.status).toBe(0); + + const binary = join(derivedData, 'Build/Products/Release-iphonesimulator/FixtureApp.app/FixtureApp'); + const contents = spawnSync('strings', ['-a', binary]).stdout?.toString() ?? ''; + for (const forbidden of [ + 'DebugBridgeTouch', '_touchesEvent', '_AXSSetAutomationEnabled', + 'IOHIDEventCreateDigitizerEvent', + ]) expect(contents).not.toContain(forbidden); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }, 360_000); }); From c02df4aff4de4a3fafc8d142ad228542ebd58e98 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 18:41:29 -0700 Subject: [PATCH 4/5] test(ios-qa): keep minimal accessor expectations current --- test/skill-e2e-ios.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/skill-e2e-ios.test.ts b/test/skill-e2e-ios.test.ts index 8d8f09c56..56211637a 100644 --- a/test/skill-e2e-ios.test.ts +++ b/test/skill-e2e-ios.test.ts @@ -171,9 +171,12 @@ describe('ios-qa E2E (no-device path)', () => { writeFileSync(join(srcDir, 'AppState.swift'), ` @Observable class AppState { - @Snapshotable var isLoggedIn: Bool = false - @Snapshotable var username: String = "" - @Snapshotable var counter: Int = 0 + // @Snapshotable + var isLoggedIn: Bool = false + // @Snapshotable + var username: String = "" + // @Snapshotable + var counter: Int = 0 var ephemeralCache: [String: Any] = [:] } `); @@ -189,7 +192,8 @@ class AppState { expect(result.specs).toHaveLength(1); expect(result.specs[0]!.fields.map(f => f.name).sort()).toEqual(['counter', 'isLoggedIn', 'username']); const generatedSwift = readFileSync(result.outputPath, 'utf-8'); - expect(generatedSwift).toContain('public enum AppStateAccessor'); + expect(generatedSwift).toContain('enum AppStateAccessor'); + expect(generatedSwift).not.toContain('public enum AppStateAccessor'); expect(generatedSwift).toContain('key: "isLoggedIn"'); expect(generatedSwift).toContain('key: "counter"'); expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable From 55efeb64368da98838dc302f76cec5c4431ae2bf Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 18:52:31 -0700 Subject: [PATCH 5/5] revert(ios-qa): remove oversized wave expansion --- bin/gstack-ios-qa-regen | 154 ---- docs/howto-ios-testing-with-gstack.md | 88 +- ios-clean/SKILL.md | 5 +- ios-clean/SKILL.md.tmpl | 5 +- ios-qa/SKILL.md | 54 +- ios-qa/SKILL.md.tmpl | 54 +- ios-qa/daemon/src/devicectl.ts | 6 - ios-qa/daemon/src/index.ts | 184 +---- ios-qa/daemon/src/tunnel-bootstrap.ts | 202 +---- ios-qa/daemon/test/daemon-integration.test.ts | 252 ------ ios-qa/daemon/test/tunnel-bootstrap.test.ts | 374 --------- .../scripts/gen-accessors-tool/Package.swift | 4 +- .../Sources/GenAccessors/main.swift | 601 ++------------ ios-qa/scripts/gen-accessors.test.ts | 759 +----------------- ios-qa/scripts/gen-accessors.ts | 742 ++--------------- ios-qa/templates/Bridges.swift.template | 279 ++----- .../DebugBridgeManager.swift.template | 29 +- ios-qa/templates/DebugBridgeTouch.h.template | 4 - ios-qa/templates/DebugBridgeTouch.m.template | 46 +- .../DebugBridgeWiring.swift.template | 38 +- ios-qa/templates/StateAccessor.swift.template | 17 +- ios-qa/templates/StateServer.swift.template | 71 +- ios-sync/SKILL.md | 51 +- ios-sync/SKILL.md.tmpl | 51 +- test/skill-e2e-ios-swift-build.test.ts | 51 +- test/skill-e2e-ios.test.ts | 12 +- 26 files changed, 483 insertions(+), 3650 deletions(-) delete mode 100755 bin/gstack-ios-qa-regen diff --git a/bin/gstack-ios-qa-regen b/bin/gstack-ios-qa-regen deleted file mode 100755 index ffaec9ffb..000000000 --- a/bin/gstack-ios-qa-regen +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env bash -# gstack-ios-qa-regen — deterministically regenerate the iOS DebugBridge -# package and the app-owned typed state accessors. - -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: gstack-ios-qa-regen --app-source --bridge-dir - - --app-source Swift source tree to scan for @Observable state - --bridge-dir Destination for the generated local DebugBridge package -EOF -} - -APP_SOURCE="" -BRIDGE_DIR="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --app-source) - [[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --app-source requires a value" >&2; exit 2; } - APP_SOURCE="$2" - shift 2 - ;; - --bridge-dir) - [[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --bridge-dir requires a value" >&2; exit 2; } - BRIDGE_DIR="$2" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "gstack-ios-qa-regen: unknown argument: $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -if [[ -z "$APP_SOURCE" || -z "$BRIDGE_DIR" ]]; then - echo "gstack-ios-qa-regen: both --app-source and --bridge-dir are required" >&2 - usage >&2 - exit 2 -fi - -if [[ ! -d "$APP_SOURCE" ]]; then - echo "gstack-ios-qa-regen: app source directory not found: $APP_SOURCE" >&2 - exit 1 -fi - -if ! command -v bun >/dev/null 2>&1; then - echo "gstack-ios-qa-regen: bun runtime not on PATH — install from https://bun.sh" >&2 - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -GSTACK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -TEMPLATE_DIR="$GSTACK_ROOT/ios-qa/templates" -GENERATOR="$GSTACK_ROOT/ios-qa/scripts/gen-accessors.ts" -VERSION_FILE="$GSTACK_ROOT/VERSION" -GENERATED_DIR="$APP_SOURCE/DebugBridgeGenerated" - -for required in "$GENERATOR" "$VERSION_FILE"; do - if [[ ! -f "$required" ]]; then - echo "gstack-ios-qa-regen: missing required gstack file: $required" >&2 - exit 1 - fi -done - -TMP_FILE="" -cleanup() { - if [[ -n "$TMP_FILE" ]]; then - rm -f "$TMP_FILE" - fi -} -trap cleanup EXIT - -# Copy through a sibling temporary file so interruption never leaves a -# truncated generated source. Preserve an unchanged destination byte-for-byte -# and metadata-for-metadata on repeated runs. -install_file() { - local source="$1" - local destination="$2" - - if [[ ! -f "$source" ]]; then - echo "gstack-ios-qa-regen: missing template: $source" >&2 - exit 1 - fi - if [[ -f "$destination" ]] && cmp -s "$source" "$destination"; then - return - fi - - mkdir -p "$(dirname "$destination")" - TMP_FILE="${destination}.tmp.$$" - cp "$source" "$TMP_FILE" - mv "$TMP_FILE" "$destination" - TMP_FILE="" -} - -# Invalidate the completion marker before changing any package source. A -# failed or interrupted regeneration must never look current to ios-sync. -mkdir -p "$GENERATED_DIR" -rm -f -- "$GENERATED_DIR/.gstack-version" - -# This is intentionally an allowlist, not a template glob. Wiring belongs to -# the consuming app and StateAccessor.swift is emitted by the parser below. -install_file "$TEMPLATE_DIR/Package.swift.template" \ - "$BRIDGE_DIR/Package.swift" -install_file "$TEMPLATE_DIR/StateServer.swift.template" \ - "$BRIDGE_DIR/Sources/DebugBridgeCore/StateServer.swift" -install_file "$TEMPLATE_DIR/DebugBridgeManager.swift.template" \ - "$BRIDGE_DIR/Sources/DebugBridgeCore/DebugBridgeManager.swift" -install_file "$TEMPLATE_DIR/Bridges.swift.template" \ - "$BRIDGE_DIR/Sources/DebugBridgeUI/Bridges.swift" -install_file "$TEMPLATE_DIR/DebugOverlay.swift.template" \ - "$BRIDGE_DIR/Sources/DebugBridgeUI/DebugOverlay.swift" -install_file "$TEMPLATE_DIR/DebugBridgeTouch.m.template" \ - "$BRIDGE_DIR/Sources/DebugBridgeTouch/DebugBridgeTouch.m" -install_file "$TEMPLATE_DIR/DebugBridgeTouch.h.template" \ - "$BRIDGE_DIR/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h" - -# Older ios-sync versions copied the entire template set flat into the app's -# generated-source directory. Those files can shadow the package modules or -# make Xcode compile two harness implementations. Remove only the explicit -# obsolete generated paths; handwritten app sources are never touched. -for obsolete in \ - "$BRIDGE_DIR/DebugBridgeWiring.swift" \ - "$BRIDGE_DIR/StateAccessor.swift" \ - "$GENERATED_DIR/Package.swift" \ - "$GENERATED_DIR/StateServer.swift" \ - "$GENERATED_DIR/DebugBridgeManager.swift" \ - "$GENERATED_DIR/Bridges.swift" \ - "$GENERATED_DIR/DebugOverlay.swift" \ - "$GENERATED_DIR/DebugBridgeTouch.m" \ - "$GENERATED_DIR/DebugBridgeTouch.h" \ - "$GENERATED_DIR/DebugBridgeWiring.swift" -do - if [[ -f "$obsolete" || -L "$obsolete" ]]; then - rm -f -- "$obsolete" - echo "gstack-ios-qa-regen: removed obsolete generated file $obsolete" - fi -done - -bun run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR" - -# Stamp only after successful accessor generation. ios-sync uses this marker -# to distinguish a complete current install from an interrupted regeneration. -install_file "$VERSION_FILE" "$GENERATED_DIR/.gstack-version" - -echo "gstack-ios-qa-regen: bridge package ready at $BRIDGE_DIR" -echo "gstack-ios-qa-regen: accessors ready at $GENERATED_DIR/StateAccessor.swift" diff --git a/docs/howto-ios-testing-with-gstack.md b/docs/howto-ios-testing-with-gstack.md index 647a77c98..1187e9a85 100644 --- a/docs/howto-ios-testing-with-gstack.md +++ b/docs/howto-ios-testing-with-gstack.md @@ -9,7 +9,7 @@ Everything below has been verified end-to-end on a real iPhone 17 Pro Max runnin - macOS with Xcode 16.0+ installed (`xcrun devicectl --version` must succeed). Xcode 16 ships the CoreDevice tunnel `devicectl` uses to reach the device over USB. - A real iPhone running iOS 16 or later. Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security. - An Apple developer team — the free personal team works fine for live-device debug deploys. You'll need the team ID (e.g. `623FYQ2M88`), not the certificate ID. Find it in Xcode → Settings → Accounts → your Apple ID → team list. The setup signs the app for your device on first deploy via `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`. -- gstack installed (`./setup` complete; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH). +- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable). - Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process. For the optional remote-agent (Tailscale) mode, you'll additionally need Tailscale installed on the Mac with `/var/run/tailscale.sock` readable. @@ -30,49 +30,28 @@ For the optional remote-agent (Tailscale) mode, you'll additionally need Tailsca The iOS `StateServer` is loopback-only **always**, even in remote mode. Identity validation happens Mac-side because the iPhone has no way to validate a Tailscale identity. -## Step 1: Generate the DebugBridge package +## Step 1: Add the DebugBridge templates to your iOS app -Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly: +The templates live at `~/.claude/skills/gstack/ios-qa/templates/` after `./setup`. The fastest install is to invoke the `/ios-qa` skill in Claude Code from your app's root — it reads your Swift source, codegens typed `@Observable` state accessors, and lays down the templates with your bundle ID. Or do it by hand: -```bash -gstack-ios-qa-regen \ - --app-source "$PWD/Sources/YourApp" \ - --bridge-dir "$PWD/DebugBridge" -``` - -The command copies an explicit allowlist of canonical templates into the local -`DebugBridge/` Swift package, generates -`DebugBridgeGenerated/StateAccessor.swift`, and writes the installed version to -`DebugBridgeGenerated/.gstack-version`. It excludes generated output from its -own schema hash, so rerunning it with unchanged source is a fast, byte-stable -cache hit. It also removes the explicit legacy generated-file set from older -flat harness layouts so stale bridge sources cannot shadow the package. - -1. Add `DebugBridge/` as a local package dependency. Depend on the - `DebugBridgeUI` product only in Debug configuration; `DebugBridgeCore` and - `DebugBridgeTouch` come in transitively. -2. Add `DebugBridgeGenerated/StateAccessor.swift` to the app target. -3. In your `@main` App init, install the UIKit resolvers before starting the - server, then register the generated accessor. Replace the example - state/accessor names with the type the generator found: +1. Copy these into a `DebugBridge/` SPM package inside your app workspace: + - `Sources/DebugBridgeCore/StateServer.swift` (from `StateServer.swift.template`) + - `Sources/DebugBridgeCore/DebugBridgeManager.swift` (from `DebugBridgeManager.swift.template`) + - `Sources/DebugBridgeTouch/DebugBridgeTouch.m` + `Sources/DebugBridgeTouch/include/DebugBridgeTouch.h` (from the two `.template` files) + - `Sources/DebugBridgeUI/Bridges.swift` (from `Bridges.swift.template`) + - `Sources/DebugBridgeUI/DebugOverlay.swift` (from `DebugOverlay.swift.template`) + - `Package.swift` (from `Package.swift.template`) +2. Add the package as a local dependency of your app. Depend on the `DebugBridgeUI` product with `condition: .when(configuration: .debug)`. `DebugBridgeCore` and `DebugBridgeTouch` come in transitively. +3. In your `@main` App init, gate the wiring on `#if DEBUG`: ```swift #if DEBUG import DebugBridgeCore + StateServer.shared.start() #if canImport(UIKit) import DebugBridgeUI - #endif - #endif - - // Inside App.init(), after appState is initialized: - #if DEBUG - #if canImport(UIKit) DebugBridgeUIWiring.installAll() #endif - DebugBridgeManager.shared.start( - appState: appState, - register: AppStateAccessor.register - ) #endif ``` @@ -130,16 +109,7 @@ GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099 ``` -If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired, -available iPhone. -Automatic selection is restricted to available iPhones and prefers a wired -phone. The daemon keeps a healthy rotated tunnel, then invalidates and -rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection -failure. -If a newly started daemon reaches an already-running target whose one-use boot -token was consumed by an earlier daemon, it verifies the bundle owner, force -relaunches that target once, waits for a fresh token, verifies ownership again, -and rotates normally. +If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device. ## Step 4: Drive the device @@ -153,39 +123,15 @@ Once the daemon is running, you have an HTTP surface at `http://127.0.0.1:9099` | `POST /session/release` | Release the lock. | bearer + session | | `GET /screenshot` | Capture a PNG of the active window. Returns `{png_base64: "..."}`. | bearer | | `GET /elements` | Accessibility-tree snapshot. | bearer | -| `GET /state/snapshot` | Dump every `// @Snapshotable` field as JSON. | bearer | -| `POST /state/restore` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier | +| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer | +| `POST /state/restore` | Atomically restore a full snapshot. | bearer + session, mutate tier | | `POST /tap` `{x,y}` | Synthesize a real UITouch at window coordinates. SwiftUI Buttons fire. | bearer + session, interact tier | | `POST /swipe` `{from_x,from_y,to_x,to_y}` | Scroll the nearest enclosing UIScrollView. | bearer + session, interact tier | | `POST /type` `{text}` | Set text on the current first responder. | bearer + session, interact tier | Mutating requests require both an `Authorization: Bearer ` header AND an `X-Session-Id` header. Read endpoints (`/screenshot`, `/elements`, `GET /state/*`) only need the bearer. -The state snapshot is opt-in per field via a standalone generator marker -comment immediately above a property. It is intentionally not a property -wrapper, so it compiles cleanly with Observation: - -```swift -@Observable -final class AppState { - // @Snapshotable - var username: String = "" - - var authToken: String = "" // never exported -} -``` - -Unmarked fields never appear in the snapshot, which keeps tokens, PII, and -auth state out of recorded fixtures by default. A marked field must be a -writable instance `var` on a file-scope observable class, with an explicit type -and an internal or public setter. Supported snapshot types are JSON-native -scalars (`String`, `Bool`, signed/unsigned integer widths, `Float`, `Double`, -`CGFloat`), arrays, String-keyed dictionaries, and Optional compositions of -those types. Snapshot keys must be unique across observable classes. The -generator reports and stops on invalid declarations, custom values, implicitly -unwrapped Optionals, nested observable classes, or duplicate keys instead of -emitting broken or lossy Swift. Restore uses two phases: every model validates -the complete input first, and only then are assignments applied on MainActor. +The state snapshot is opt-in per field via a `@Snapshotable` property wrapper on your canonical state struct. Fields you don't annotate never appear in the snapshot, which keeps tokens, PII, and auth state out of recorded fixtures by default. ## Step 5: Make remote agents work (optional) diff --git a/ios-clean/SKILL.md b/ios-clean/SKILL.md index 6c466896b..127649646 100644 --- a/ios-clean/SKILL.md +++ b/ios-clean/SKILL.md @@ -821,8 +821,9 @@ Each item is reverted only after AskUserQuestion confirmation: 1. The `DebugBridge` SPM target from `Package.swift`. 2. The `#if DEBUG` block in the app's `@main` entry that calls `DebugBridgeManager.shared.start()`. -3. Any standalone `// @Snapshotable` generator marker comments on the - canonical app state class. +3. Any `@Snapshotable` property wrappers on the canonical app state struct + (the codegen-detection markers — the wrapper file lives inside + DebugBridge so removing the SPM dep removes the wrapper too). 4. Generated `StateAccessor.swift` files anywhere under the app source. 5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the device (best-effort — only works if device is connected when /ios-clean diff --git a/ios-clean/SKILL.md.tmpl b/ios-clean/SKILL.md.tmpl index 21a1d5495..3a64481a9 100644 --- a/ios-clean/SKILL.md.tmpl +++ b/ios-clean/SKILL.md.tmpl @@ -50,8 +50,9 @@ Each item is reverted only after AskUserQuestion confirmation: 1. The `DebugBridge` SPM target from `Package.swift`. 2. The `#if DEBUG` block in the app's `@main` entry that calls `DebugBridgeManager.shared.start()`. -3. Any standalone `// @Snapshotable` generator marker comments on the - canonical app state class. +3. Any `@Snapshotable` property wrappers on the canonical app state struct + (the codegen-detection markers — the wrapper file lives inside + DebugBridge so removing the SPM dep removes the wrapper too). 4. Generated `StateAccessor.swift` files anywhere under the app source. 5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the device (best-effort — only works if device is connected when /ios-clean diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md index 24624be5f..a5d4575d4 100644 --- a/ios-qa/SKILL.md +++ b/ios-qa/SKILL.md @@ -869,33 +869,17 @@ fi ## Phase 1: Read source, plan codegen 1. Walk the app source (passed as `--source `) and identify all `@Observable` - classes. Note any property immediately preceded by the generator marker - comment `// @Snapshotable` — those are the snapshot-eligible fields. The - marker is a comment so it composes with the `@Observable` macro. Each - marked field must belong to a file-scope observable class and be a writable - instance `var` with an explicit type and an internal or public setter. - Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths, - `Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their - Optional compositions. Keys must be unique across observable classes. - Codegen stops with a source diagnostic instead of emitting a broken or - lossy harness when any of these constraints is violated. -2. Show the user the accessor list and ask whether to install the DebugBridge + classes. Note any property marked with the `@Snapshotable` wrapper — those + are the snapshot-eligible fields. +2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input `. + First invocation builds the swift-syntax dependency tree (cold: 2-5 min). + Subsequent runs are content-hash-cached and finish in ~50ms. +3. Show the user the accessor list and ask whether to install the DebugBridge SPM dependency into their `Package.swift` (one AskUserQuestion). ## Phase 2: Bootstrap the device bridge -1. Generate the canonical local bridge package, typed accessors, and installed - version marker with one deterministic command: - ```bash - ~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ - --app-source "" \ - --bridge-dir "/DebugBridge" - ``` - The regenerator also removes the explicit obsolete flat-file set created by - older ios-sync versions, preventing a stale second harness from remaining - in the app target. -2. Add the generated `DebugBridge` local SPM dependency to the app's - `Package.swift`. The package +1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package ships three Debug-config-only library products: - `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols. - `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch @@ -905,35 +889,27 @@ fi The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)` (transitively pulls in Core + Touch). Release builds refuse to link these targets. -3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: +2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: ```swift #if DEBUG import DebugBridgeCore + StateServer.shared.start() #if canImport(UIKit) import DebugBridgeUI - // Install resolvers before StateServer opens its listener. DebugBridgeUIWiring.installAll() #endif - // Replace AppState/AppStateAccessor with the type discovered in Phase 1. - DebugBridgeManager.shared.start( - appState: appState, - register: AppStateAccessor.register - ) #endif ``` -4. Build + deploy to the device with `xcodebuild -scheme +3. Build + deploy to the device with `xcodebuild -scheme -destination 'platform=iOS,id=' build install`. -5. Launch via `devicectl device process launch --device --console `. +4. Launch via `devicectl device process launch --device --console `. Capture the boot token printed to `os_log` on first run. -6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon +5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another daemon is alive, the second invocation discovers its port and connects. -7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a +6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a fresh in-memory-only token. The boot token becomes useless ~5s later. Anything scraping `os_log` past this point sees a dead credential. - If a fresh daemon finds the app running after another daemon consumed that - one-use token, it verifies the bundle owner, relaunches the target once, - waits for the new token, verifies ownership again, and then rotates. ## Phase 3: Vision-driven agent loop @@ -941,7 +917,7 @@ Each iteration: 1. `GET /screenshot` (via daemon) → save PNG. 2. `GET /elements` → accessibility tree. -3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state. +3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state. 4. Decide next action based on what's on the screen vs the test goal. 5. `POST /session/acquire` to grab the device lock. 6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/` write. @@ -1005,7 +981,7 @@ live. | `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed | | `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote ` on the Mac | | `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture | -| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists | +| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s | | `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies | | `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot | diff --git a/ios-qa/SKILL.md.tmpl b/ios-qa/SKILL.md.tmpl index 78f9454b1..e93d2831a 100644 --- a/ios-qa/SKILL.md.tmpl +++ b/ios-qa/SKILL.md.tmpl @@ -97,33 +97,17 @@ fi ## Phase 1: Read source, plan codegen 1. Walk the app source (passed as `--source `) and identify all `@Observable` - classes. Note any property immediately preceded by the generator marker - comment `// @Snapshotable` — those are the snapshot-eligible fields. The - marker is a comment so it composes with the `@Observable` macro. Each - marked field must belong to a file-scope observable class and be a writable - instance `var` with an explicit type and an internal or public setter. - Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths, - `Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their - Optional compositions. Keys must be unique across observable classes. - Codegen stops with a source diagnostic instead of emitting a broken or - lossy harness when any of these constraints is violated. -2. Show the user the accessor list and ask whether to install the DebugBridge + classes. Note any property marked with the `@Snapshotable` wrapper — those + are the snapshot-eligible fields. +2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input `. + First invocation builds the swift-syntax dependency tree (cold: 2-5 min). + Subsequent runs are content-hash-cached and finish in ~50ms. +3. Show the user the accessor list and ask whether to install the DebugBridge SPM dependency into their `Package.swift` (one AskUserQuestion). ## Phase 2: Bootstrap the device bridge -1. Generate the canonical local bridge package, typed accessors, and installed - version marker with one deterministic command: - ```bash - ~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ - --app-source "" \ - --bridge-dir "/DebugBridge" - ``` - The regenerator also removes the explicit obsolete flat-file set created by - older ios-sync versions, preventing a stale second harness from remaining - in the app target. -2. Add the generated `DebugBridge` local SPM dependency to the app's - `Package.swift`. The package +1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package ships three Debug-config-only library products: - `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols. - `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch @@ -133,35 +117,27 @@ fi The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)` (transitively pulls in Core + Touch). Release builds refuse to link these targets. -3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: +2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: ```swift #if DEBUG import DebugBridgeCore + StateServer.shared.start() #if canImport(UIKit) import DebugBridgeUI - // Install resolvers before StateServer opens its listener. DebugBridgeUIWiring.installAll() #endif - // Replace AppState/AppStateAccessor with the type discovered in Phase 1. - DebugBridgeManager.shared.start( - appState: appState, - register: AppStateAccessor.register - ) #endif ``` -4. Build + deploy to the device with `xcodebuild -scheme +3. Build + deploy to the device with `xcodebuild -scheme -destination 'platform=iOS,id=' build install`. -5. Launch via `devicectl device process launch --device --console `. +4. Launch via `devicectl device process launch --device --console `. Capture the boot token printed to `os_log` on first run. -6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon +5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another daemon is alive, the second invocation discovers its port and connects. -7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a +6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a fresh in-memory-only token. The boot token becomes useless ~5s later. Anything scraping `os_log` past this point sees a dead credential. - If a fresh daemon finds the app running after another daemon consumed that - one-use token, it verifies the bundle owner, relaunches the target once, - waits for the new token, verifies ownership again, and then rotates. ## Phase 3: Vision-driven agent loop @@ -169,7 +145,7 @@ Each iteration: 1. `GET /screenshot` (via daemon) → save PNG. 2. `GET /elements` → accessibility tree. -3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state. +3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state. 4. Decide next action based on what's on the screen vs the test goal. 5. `POST /session/acquire` to grab the device lock. 6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/` write. @@ -233,7 +209,7 @@ live. | `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed | | `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote ` on the Mac | | `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture | -| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists | +| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s | | `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies | | `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot | diff --git a/ios-qa/daemon/src/devicectl.ts b/ios-qa/daemon/src/devicectl.ts index c17aec751..ee1696eb9 100644 --- a/ios-qa/daemon/src/devicectl.ts +++ b/ios-qa/daemon/src/devicectl.ts @@ -13,10 +13,7 @@ export interface DeviceEntry { identifier: string; name: string; model: string; - platform: string; // "iOS" | "iPadOS" | "visionOS" | ... - deviceType: string; // "iPhone" | "iPad" | "realityDevice" | ... state: string; // "connected" | "available" | "available (paired)" | ... - transport: string; // "wired" on USB devices; empty for stale/unavailable entries paired: boolean; } @@ -86,10 +83,7 @@ export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] { identifier: String(d.identifier ?? ''), name: String(props?.name ?? 'unknown'), model: String(hw?.productType ?? 'unknown'), - platform: String(hw?.platform ?? ''), - deviceType: String(hw?.deviceType ?? ''), state: String(conn?.tunnelState ?? 'unknown'), - transport: String(conn?.transportType ?? ''), paired: pairingState === 'paired', }; }); diff --git a/ios-qa/daemon/src/index.ts b/ios-qa/daemon/src/index.ts index f3a70c89f..82628b136 100644 --- a/ios-qa/daemon/src/index.ts +++ b/ios-qa/daemon/src/index.ts @@ -62,36 +62,16 @@ export async function startDaemon(opts: DaemonOptions): Promise | null = null; + let cachedTunnelAt = 0; const getTunnel = async (): Promise => { - // A successful bootstrap consumes and deletes the app's one-shot boot - // token. Keep that rotated tunnel for this daemon's lifetime instead of - // trying to bootstrap it again on a timer. Failed attempts are not cached. - if (tunnel) return tunnel; - if (!opts.tunnelProvider) return null; - - // Multiple first requests can arrive before bootstrap completes. Share - // one provider call so they cannot race through independent rotations. - if (!tunnelInFlight) { - tunnelInFlight = Promise.resolve() - .then(() => opts.tunnelProvider!()) - .then((candidate) => { - if (candidate) tunnel = candidate; - return candidate; - }) - .finally(() => { - tunnelInFlight = null; - }); + // Cache the tunnel for 30s; refresh on demand. + if (tunnel && Date.now() - cachedTunnelAt < 30_000) return tunnel; + if (opts.tunnelProvider) { + tunnel = await opts.tunnelProvider(); + cachedTunnelAt = Date.now(); } - return tunnelInFlight; - }; - - const invalidateTunnel = (failedTunnel: DeviceTunnel): void => { - // A late response from the old app must not evict a tunnel that another - // request has already refreshed. Object identity gives each bootstrap a - // cheap generation token without exposing generation state elsewhere. - if (tunnel === failedTunnel) tunnel = null; + return tunnel; }; // 2. Tailnet probe (fail-closed). @@ -106,7 +86,7 @@ export async function startDaemon(opts: DaemonOptions): Promise { - await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel }); + await handleLoopback({ req, res, tokenStore, getTunnel }); }); // Use port 0 for OS-assigned port when test/random port collisions are a risk. const requestedPort = opts.loopbackPort; @@ -117,7 +97,7 @@ export async function startDaemon(opts: DaemonOptions): Promise { - await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel }); + await handleLoopback({ req, res, tokenStore, getTunnel }); }); let v6Bound = false; try { @@ -138,7 +118,6 @@ export async function startDaemon(opts: DaemonOptions): Promise Promise; - invalidateTunnel: (failedTunnel: DeviceTunnel) => void; // Explicit security-log + allowlist paths (default to env-derived when undefined). auditPath?: string; attemptsPath?: string; allowlistPath?: string; } -type DeviceProxyResponse = Awaited>; -const RECOVERABLE_SOCKET_ERRORS = new Set([ - 'ECONNABORTED', - 'ECONNREFUSED', - 'ECONNRESET', - 'EHOSTUNREACH', - 'ENETUNREACH', - 'EPIPE', - 'ETIMEDOUT', -]); - -function localProxyError(status: number, error: string): DeviceProxyResponse { - const body = Buffer.from(JSON.stringify({ error }, sanitizeReplacer)); - return { - status, - headers: { 'content-type': 'application/json', 'content-length': String(body.length) }, - body, - }; -} - -async function proxyAttempt(opts: Parameters[0]): Promise { - try { - return await proxyToDevice(opts); - } catch (err) { - const code = (err as { code?: string }).code; - // CoreDevice can surface the same stale route as several different socket - // failures while an app is being relaunched or replaced. Normalize those - // failures so the cache recovery path below can handle all of them. - if (code && RECOVERABLE_SOCKET_ERRORS.has(code)) { - return localProxyError(503, 'device_disconnected'); - } - throw err; - } -} - -function shouldRefreshTunnel(upstream: DeviceProxyResponse): boolean { - // A relaunched app has a new in-memory bearer and rejects the daemon's old - // rotated token. A redeploy can instead leave the old CoreDevice route - // refusing connections or timing out. Both cases require a fresh bootstrap. - if (upstream.status === 401) return true; - if (upstream.status !== 503 && upstream.status !== 504) return false; - try { - const body = JSON.parse(upstream.body.toString('utf-8')) as { error?: string }; - return body.error === 'device_disconnected' || body.error === 'upstream_timeout'; - } catch { - return false; - } -} - -function canReplayAfterRefresh(inbound: IncomingMessage, upstream: DeviceProxyResponse): boolean { - // A 401 proves the stale bearer was rejected before StateServer dispatched - // the operation, so retrying is safe even for a mutation. Connection loss - // is ambiguous: the app may have applied a tap/write before its response was - // lost. Replay only read-only requests in that case to prevent double taps. - if (upstream.status === 401) return true; - const method = inbound.method ?? 'GET'; - return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'; -} - -async function proxyWithTunnelRecovery(opts: { - inbound: IncomingMessage; - body: Buffer; - sessionId: string | null; - agentIdentity?: string; - getTunnel: HandlerCtx['getTunnel']; - invalidateTunnel: HandlerCtx['invalidateTunnel']; -}): Promise<{ tunnel: DeviceTunnel; upstream: DeviceProxyResponse } | null> { - let tunnel = await opts.getTunnel(); - if (!tunnel) return null; - - const makeAttempt = (candidate: DeviceTunnel) => proxyAttempt({ - inbound: opts.inbound, - body: opts.body, - tunnel: candidate, - sessionId: opts.sessionId, - agentIdentity: opts.agentIdentity, - }); - - let upstream = await makeAttempt(tunnel); - if (!shouldRefreshTunnel(upstream)) return { tunnel, upstream }; - - const failedTunnel = tunnel; - const replaySafe = canReplayAfterRefresh(opts.inbound, upstream); - opts.invalidateTunnel(tunnel); - const refreshed = await opts.getTunnel(); - if (!refreshed) return replaySafe ? null : { tunnel: failedTunnel, upstream }; - - // The replacement is now cached for the next request, but never replay an - // ambiguous mutation whose response was lost: doing so could double-tap or - // apply a state transition twice. - if (!replaySafe) return { tunnel: failedTunnel, upstream }; - - tunnel = refreshed; - upstream = await makeAttempt(tunnel); - // Do not loop forever if the replacement app is itself unavailable. Leave - // the cache empty so the next independent request can bootstrap again. - if (shouldRefreshTunnel(upstream)) opts.invalidateTunnel(tunnel); - return { tunnel, upstream }; -} - function readBody(req: IncomingMessage, maxBytes = 1_048_576): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; @@ -350,7 +228,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void { * loopback bind itself is the boundary). */ async function handleLoopback(ctx: HandlerCtx): Promise { - const { req, res, tokenStore, getTunnel, invalidateTunnel } = ctx; + const { req, res, tokenStore, getTunnel } = ctx; const url = parseUrl(req.url ?? '/'); const path = url.pathname ?? '/'; const method = req.method ?? 'GET'; @@ -384,23 +262,16 @@ async function handleLoopback(ctx: HandlerCtx): Promise { } // Other endpoints — proxy to the device. + const tunnel = await getTunnel(); + if (!tunnel) { + sendJson(res, 503, { error: 'device_not_connected' }); + return; + } const body = await readBody(req); if ('error' in body) { sendJson(res, 413, body); return; } const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null; const agentIdentity = (req.headers['x-agent-identity'] as string | undefined) ?? undefined; - const proxied = await proxyWithTunnelRecovery({ - inbound: req, - body, - sessionId, - agentIdentity, - getTunnel, - invalidateTunnel, - }); - if (!proxied) { - sendJson(res, 503, { error: 'device_not_connected' }); - return; - } - const { upstream } = proxied; + const upstream = await proxyToDevice({ inbound: req, body, tunnel, sessionId, agentIdentity }); res.writeHead(upstream.status, upstream.headers); res.end(upstream.body); } catch (err) { @@ -416,7 +287,7 @@ interface TailnetCtx extends HandlerCtx { * Tailnet handler — locked allowlist + capability tiers. */ async function handleTailnet(ctx: TailnetCtx): Promise { - const { req, res, tokenStore, getTunnel, invalidateTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx; + const { req, res, tokenStore, getTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx; const url = parseUrl(req.url ?? '/'); const path = url.pathname ?? '/'; const method = req.method ?? 'GET'; @@ -504,20 +375,19 @@ async function handleTailnet(ctx: TailnetCtx): Promise { } // Proxy to device. - const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null; - const proxied = await proxyWithTunnelRecovery({ - inbound: req, - body, - sessionId, - agentIdentity: session.identity, - getTunnel, - invalidateTunnel, - }); - if (!proxied) { + const tunnel = await getTunnel(); + if (!tunnel) { sendJson(res, 503, { error: 'device_not_connected' }); return; } - const { tunnel, upstream } = proxied; + const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null; + const upstream = await proxyToDevice({ + inbound: req, + body, + tunnel, + sessionId, + agentIdentity: session.identity, + }); // Audit the action (mutating endpoints only). if (requiredCapability !== 'observe') { diff --git a/ios-qa/daemon/src/tunnel-bootstrap.ts b/ios-qa/daemon/src/tunnel-bootstrap.ts index f1be1ca48..aa6636938 100644 --- a/ios-qa/daemon/src/tunnel-bootstrap.ts +++ b/ios-qa/daemon/src/tunnel-bootstrap.ts @@ -5,8 +5,6 @@ // 2. launch the app on it (no-op if already running) // 3. wait briefly for the in-app StateServer to start // 4. copy the boot token from the app's sandbox via devicectl copy from -// If an earlier daemon already consumed it, relaunch the app once to mint -// a fresh boot token, then verify the relaunched StateServer again. // 5. POST /auth/rotate to swap boot token → fresh in-memory token // 6. return a DeviceTunnel pointing at the device's IPv6 with the rotated // bearer that subsequent proxied requests carry @@ -16,7 +14,6 @@ // live token, which it scopes per-tailnet-session via /auth/mint. import { randomBytes } from 'crypto'; -import { spawnSync } from 'child_process'; import type { DeviceTunnel } from './proxy'; import { listDevices, @@ -24,13 +21,12 @@ import { isAppRunning, launchApp, copyFileFromAppContainer, - type DeviceEntry, type SpawnImpl, type ResolveImpl, } from './devicectl'; export interface BootstrapOptions { - /** Target iPhone UDID. If null, picks the best connected paired iPhone. */ + /** Target device UDID. If null, picks the first connected paired device. */ udid?: string; /** Bundle ID of the iOS app hosting the StateServer. */ bundleId: string; @@ -57,85 +53,10 @@ export type BootstrapErrorReason = | 'launch_failed' | 'device_locked' | 'state_server_unreachable' - | 'wrong_app' | 'boot_token_unavailable' | 'rotate_failed' | 'resolve_failed'; -function isIPhoneDevice(device: DeviceEntry): boolean { - const platform = device.platform.trim().toLowerCase(); - const deviceType = device.deviceType.trim().toLowerCase(); - const model = device.model.trim().toLowerCase(); - - // productType is present even on older CoreDevice versions. Prefer the - // explicit platform/type fields when available, but retain productType as - // a compatibility fallback. An explicit non-iOS platform always loses. - if (platform && platform !== 'ios') return false; - return deviceType === 'iphone' || model.startsWith('iphone'); -} - -function isAvailableDevice(device: Pick): boolean { - const state = device.state.trim().toLowerCase(); - const transport = device.transport.trim().toLowerCase(); - // Xcode 26.6 / iOS 27 beta can report a USB-reachable iPhone as - // tunnelState=disconnected until the next devicectl command establishes - // the CoreDevice tunnel. The wired transport is the authoritative signal - // in that transitional state. Stale devices have no wired transport. - return state === 'connected' - || state.startsWith('available') - || (state === 'disconnected' && transport === 'wired'); -} - -function defaultDeviceRank(device: DeviceEntry): number { - if (!device.paired || !isIPhoneDevice(device) || !isAvailableDevice(device)) return -1; - - const state = device.state.trim().toLowerCase(); - const transport = device.transport.trim().toLowerCase(); - // Prefer the USB-connected phone the user is actively working with. Then - // prefer an established CoreDevice tunnel over a merely available device. - return (transport === 'wired' ? 100 : 0) - + (state === 'connected' ? 10 : 0) - + (state.startsWith('available') ? 1 : 0); -} - -function pickDefaultDevice(devices: DeviceEntry[]): DeviceEntry | undefined { - let best: DeviceEntry | undefined; - let bestRank = -1; - for (const device of devices) { - const rank = defaultDeviceRank(device); - if (rank > bestRank) { - best = device; - bestRank = rank; - } - } - return best; -} - -const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, { - stdio: 'pipe', - timeout: 60_000, -}); - -function relaunchApp( - udid: string, - bundleId: string, - spawn: SpawnImpl = defaultSpawn, -): { ok: true } | { ok: false; error: 'device_locked' | 'launch_failed'; detail?: string } { - const r = spawn('xcrun', [ - 'devicectl', 'device', 'process', 'launch', - '--device', udid, - '--terminate-existing', - bundleId, - ]); - if (r.status === 0) return { ok: true }; - - const detail = `${r.stderr?.toString() ?? ''}${r.stdout?.toString() ?? ''}`.trim(); - if (detail.includes('was not, or could not be, unlocked')) { - return { ok: false, error: 'device_locked', detail }; - } - return { ok: false, error: 'launch_failed', detail }; -} - /** * Bootstrap a real CoreDevice tunnel to an iOS app's StateServer. Used by * the daemon's default tunnelProvider when GSTACK_IOS_TARGET_UDID is set @@ -156,39 +77,9 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise d.identifier === opts.udid) - : pickDefaultDevice(devices); + : devices.find((d) => d.paired) ?? devices[0]; if (!target) { - if (opts.udid) { - return { ok: false, error: 'device_not_found', detail: opts.udid }; - } - const pairedIPhone = devices.find((d) => d.paired && isIPhoneDevice(d)); - if (pairedIPhone) { - return { - ok: false, - error: 'device_not_found', - detail: `paired iPhone ${pairedIPhone.name} (${pairedIPhone.identifier}) is ${pairedIPhone.state}; connect it over USB and unlock it`, - }; - } - const firstIPhone = devices.find(isIPhoneDevice); - if (!firstIPhone) { - return { - ok: false, - error: 'device_not_found', - detail: 'no iPhone is connected; non-iOS devices are not eligible for iOS QA', - }; - } - return { - ok: false, - error: 'no_paired_device', - detail: `device ${firstIPhone.name} (${firstIPhone.identifier}) is ${firstIPhone.state}; run \`xcrun devicectl manage pair --device ${firstIPhone.identifier}\` and tap Trust on the iPhone`, - }; - } - if (!isIPhoneDevice(target)) { - return { - ok: false, - error: 'device_not_found', - detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone`, - }; + return { ok: false, error: 'device_not_found', detail: opts.udid }; } if (!target.paired) { return { @@ -197,13 +88,6 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise => { - const deadline = Date.now() + startupTimeoutMs; - while (Date.now() < deadline) { - try { - const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, { - signal: AbortSignal.timeout(2_000), - }); - if (r.ok) { - const health = await r.json().catch(() => null) as { bundle_id?: string } | null; - // Older bridges did not identify their bundle. Preserve compatibility, - // but reject an explicit mismatch from current bridges: another debug - // app already owns the fixed StateServer port on this device. - if (health?.bundle_id && health.bundle_id !== opts.bundleId) { - return { - ok: false, - error: 'wrong_app', - detail: `expected ${opts.bundleId} but StateServer port ${port} belongs to ${health.bundle_id}; terminate the other debug app`, - }; - } - return null; - } - } catch { /* retry */ } - await new Promise((res) => setTimeout(res, 250)); - } - return { - ok: false, - error: 'state_server_unreachable', - detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms`, - }; - }; + const deadline = Date.now() + startupTimeoutMs; + let healthOK = false; + while (Date.now() < deadline) { + try { + const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, { + signal: AbortSignal.timeout(2_000), + }); + if (r.ok) { healthOK = true; break; } + } catch { /* retry */ } + await new Promise((res) => setTimeout(res, 250)); + } + if (!healthOK) { + return { ok: false, error: 'state_server_unreachable', detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms` }; + } - const healthFailure = await waitForStateServer(); - if (healthFailure) return healthFailure; - - const readBootToken = () => copyFileFromAppContainer({ + const bootToken = copyFileFromAppContainer({ udid: target.identifier, bundleId: opts.bundleId, sourceRelativePath: tokenPath, spawn, }); - - let bootToken = readBootToken(); if (!bootToken) { - // A healthy running app can lack a boot token when an earlier daemon - // already rotated it. A new daemon has no way to recover that in-memory - // bearer, so restart exactly once to make StateServer mint a fresh one. - // The explicit bundle check above prevents disrupting an unrelated app - // that happens to own the fixed StateServer port. - const relaunched = relaunchApp(target.identifier, opts.bundleId, spawn); - if (!relaunched.ok) { - return { ok: false, error: relaunched.error, detail: relaunched.detail }; - } - - // The token is written before StateServer opens its listener. Waiting for - // it first prevents a stale response from the terminating process from - // being mistaken for readiness of the replacement process. - const tokenDeadline = Date.now() + startupTimeoutMs; - while (!bootToken && Date.now() < tokenDeadline) { - bootToken = readBootToken(); - if (!bootToken) await new Promise((res) => setTimeout(res, 250)); - } - if (!bootToken) { - return { - ok: false, - error: 'boot_token_unavailable', - detail: `couldn't read ${tokenPath} from ${opts.bundleId} after relaunch`, - }; - } - - const relaunchedHealthFailure = await waitForStateServer(); - if (relaunchedHealthFailure) return relaunchedHealthFailure; + return { ok: false, error: 'boot_token_unavailable', detail: `couldn't read ${tokenPath} from ${opts.bundleId}` }; } // Step 5: rotate the boot token to a fresh in-memory-only one. diff --git a/ios-qa/daemon/test/daemon-integration.test.ts b/ios-qa/daemon/test/daemon-integration.test.ts index b41144ecd..b131cc3ff 100644 --- a/ios-qa/daemon/test/daemon-integration.test.ts +++ b/ios-qa/daemon/test/daemon-integration.test.ts @@ -132,258 +132,6 @@ describe('daemon — loopback listener', () => { expect(lastReq?.headers['x-session-id']).toBe('sess-loopback-1'); }); - test('concurrent first requests share one tunnel bootstrap', async () => { - let bootstraps = 0; - let releaseBootstrap!: () => void; - let markBootstrapStarted!: () => void; - const bootstrapStarted = new Promise((resolve) => { markBootstrapStarted = resolve; }); - const bootstrapGate = new Promise((resolve) => { releaseBootstrap = resolve; }); - const tunnel: DeviceTunnel = { - udid: 'STUB-UDID', - ipv6Addr: '127.0.0.1', - port: stub.port, - bootTokenRotated: STATE_SERVER_TOKEN, - }; - const d = await startDaemon({ - loopbackPort: 0, - tailnetEnabled: false, - pidfilePath: join(workDir, 'daemon-concurrent-bootstrap.pid'), - tunnelProvider: async () => { - bootstraps++; - markBootstrapStarted(); - await bootstrapGate; - return tunnel; - }, - }); - if ('error' in d) throw new Error(d.error); - - try { - const base = `http://127.0.0.1:${d.loopbackPort}`; - const requests = [ - fetchWith('GET', `${base}/screenshot`), - fetchWith('GET', `${base}/screenshot`), - fetchWith('GET', `${base}/screenshot`), - ]; - await bootstrapStarted; - await new Promise((resolve) => setTimeout(resolve, 25)); - releaseBootstrap(); - const responses = await Promise.all(requests); - expect(responses.map((response) => response.status)).toEqual([200, 200, 200]); - expect(bootstraps).toBe(1); - } finally { - releaseBootstrap(); - await d.close(); - } - }); - - test('reuses a healthy rotated tunnel beyond the old 30-second boundary', async () => { - let bootstraps = 0; - const tunnel: DeviceTunnel = { - udid: 'STUB-UDID', - ipv6Addr: '127.0.0.1', - port: stub.port, - bootTokenRotated: STATE_SERVER_TOKEN, - }; - const d = await startDaemon({ - loopbackPort: 0, - tailnetEnabled: false, - pidfilePath: join(workDir, 'daemon-one-shot-bootstrap.pid'), - tunnelProvider: async () => { - bootstraps++; - if (bootstraps > 1) throw new Error('one-shot boot token was already consumed'); - return tunnel; - }, - }); - if ('error' in d) throw new Error(d.error); - - const realNow = Date.now; - const firstRequestAt = realNow(); - try { - const base = `http://127.0.0.1:${d.loopbackPort}`; - const first = await fetchWith('GET', `${base}/screenshot`); - expect(first.status).toBe(200); - - Date.now = () => firstRequestAt + 30_001; - const later = await fetchWith('GET', `${base}/screenshot`); - expect(later.status).toBe(200); - expect(bootstraps).toBe(1); - } finally { - Date.now = realNow; - await d.close(); - } - }); - - test('401 after app relaunch invalidates the token and concurrent requests share one rebootstrap', async () => { - let bootstraps = 0; - let markRefreshStarted!: () => void; - let releaseRefresh!: () => void; - const refreshStarted = new Promise((resolve) => { markRefreshStarted = resolve; }); - const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; }); - const staleTunnel: DeviceTunnel = { - udid: 'STUB-UDID', - ipv6Addr: '127.0.0.1', - port: stub.port, - bootTokenRotated: 'expired-after-relaunch', - }; - const refreshedTunnel: DeviceTunnel = { - ...staleTunnel, - bootTokenRotated: STATE_SERVER_TOKEN, - }; - const d = await startDaemon({ - loopbackPort: 0, - tailnetEnabled: false, - pidfilePath: join(workDir, 'daemon-relaunch-refresh.pid'), - tunnelProvider: async () => { - bootstraps++; - if (bootstraps === 1) return staleTunnel; - if (bootstraps === 2) { - markRefreshStarted(); - await refreshGate; - return refreshedTunnel; - } - throw new Error('concurrent 401s caused duplicate bootstraps'); - }, - }); - if ('error' in d) throw new Error(d.error); - - const requestStart = stub.receivedRequests.length; - try { - const base = `http://127.0.0.1:${d.loopbackPort}`; - const requests = [ - fetchWith('GET', `${base}/screenshot`), - fetchWith('GET', `${base}/screenshot`), - fetchWith('GET', `${base}/screenshot`), - ]; - await refreshStarted; - await new Promise((resolve) => setTimeout(resolve, 25)); - expect(bootstraps).toBe(2); - releaseRefresh(); - - const responses = await Promise.all(requests); - expect(responses.map((response) => response.status)).toEqual([200, 200, 200]); - const attempts = stub.receivedRequests.slice(requestStart); - expect(attempts.filter((request) => request.headers.authorization === 'Bearer expired-after-relaunch')).toHaveLength(3); - expect(attempts.filter((request) => request.headers.authorization === `Bearer ${STATE_SERVER_TOKEN}`)).toHaveLength(3); - - const healthyReuse = await fetchWith('GET', `${base}/screenshot`); - expect(healthyReuse.status).toBe(200); - expect(bootstraps).toBe(2); - } finally { - releaseRefresh(); - await d.close(); - } - }); - - test('connection failure after redeploy reboots the tunnel once and keeps the replacement cached', async () => { - const deadPort = await new Promise((resolve, reject) => { - const reservation = createServer(); - reservation.once('error', reject); - reservation.listen(0, '127.0.0.1', () => { - const address = reservation.address(); - const port = typeof address === 'object' && address ? address.port : 0; - reservation.close((err) => err ? reject(err) : resolve(port)); - }); - }); - let bootstraps = 0; - const d = await startDaemon({ - loopbackPort: 0, - tailnetEnabled: false, - pidfilePath: join(workDir, 'daemon-redeploy-refresh.pid'), - tunnelProvider: async () => { - bootstraps++; - if (bootstraps === 1) { - return { - udid: 'STUB-UDID', - ipv6Addr: '127.0.0.1', - port: deadPort, - bootTokenRotated: 'old-deploy-token', - }; - } - if (bootstraps === 2) { - return { - udid: 'STUB-UDID', - ipv6Addr: '127.0.0.1', - port: stub.port, - bootTokenRotated: STATE_SERVER_TOKEN, - }; - } - throw new Error('healthy replacement tunnel was not reused'); - }, - }); - if ('error' in d) throw new Error(d.error); - - try { - const base = `http://127.0.0.1:${d.loopbackPort}`; - const recovered = await fetchWith('GET', `${base}/screenshot`); - expect(recovered.status).toBe(200); - expect(JSON.parse(recovered.bodyText)).toEqual({ png_base64: 'abc=' }); - expect(bootstraps).toBe(2); - - const healthyReuse = await fetchWith('GET', `${base}/screenshot`); - expect(healthyReuse.status).toBe(200); - expect(bootstraps).toBe(2); - } finally { - await d.close(); - } - }); - - test('connection failure refreshes but never replays an ambiguous tap', async () => { - const deadPort = await new Promise((resolve, reject) => { - const reservation = createServer(); - reservation.once('error', reject); - reservation.listen(0, '127.0.0.1', () => { - const address = reservation.address(); - const port = typeof address === 'object' && address ? address.port : 0; - reservation.close((err) => err ? reject(err) : resolve(port)); - }); - }); - let bootstraps = 0; - const d = await startDaemon({ - loopbackPort: 0, - tailnetEnabled: false, - pidfilePath: join(workDir, 'daemon-mutation-no-replay.pid'), - tunnelProvider: async () => { - bootstraps++; - return bootstraps === 1 - ? { - udid: 'STUB-UDID', - ipv6Addr: '127.0.0.1', - port: deadPort, - bootTokenRotated: 'old-deploy-token', - } - : { - udid: 'STUB-UDID', - ipv6Addr: '127.0.0.1', - port: stub.port, - bootTokenRotated: STATE_SERVER_TOKEN, - }; - }, - }); - if ('error' in d) throw new Error(d.error); - - const beforeTaps = stub.receivedRequests.filter((request) => request.path === '/tap').length; - try { - const base = `http://127.0.0.1:${d.loopbackPort}`; - const ambiguous = await fetchWith('POST', `${base}/tap`, { - headers: { 'x-session-id': 'old-session', 'content-type': 'application/json' }, - body: JSON.stringify({ x: 10, y: 20 }), - }); - expect(ambiguous.status).toBe(503); - expect(bootstraps).toBe(2); - expect(stub.receivedRequests.filter((request) => request.path === '/tap')).toHaveLength(beforeTaps); - - const explicitRetry = await fetchWith('POST', `${base}/tap`, { - headers: { 'x-session-id': 'new-session', 'content-type': 'application/json' }, - body: JSON.stringify({ x: 10, y: 20 }), - }); - expect(explicitRetry.status).toBe(200); - expect(stub.receivedRequests.filter((request) => request.path === '/tap')).toHaveLength(beforeTaps + 1); - expect(bootstraps).toBe(2); - } finally { - await d.close(); - } - }); - test('returns 503 when no device tunnel is provided', async () => { // Force tunnel provider to return null by closing + restarting with null provider. await daemon.close(); diff --git a/ios-qa/daemon/test/tunnel-bootstrap.test.ts b/ios-qa/daemon/test/tunnel-bootstrap.test.ts index 37af481be..188659b78 100644 --- a/ios-qa/daemon/test/tunnel-bootstrap.test.ts +++ b/ios-qa/daemon/test/tunnel-bootstrap.test.ts @@ -105,219 +105,6 @@ describe('bootstrapTunnel', () => { } }); - test('never auto-selects a connected paired Vision Pro ahead of an iPhone', async () => { - const spawn = makeSpawn([ - { - argsMatch: /devicectl list devices/, - jsonOutput: { - result: { devices: [ - { - identifier: 'VISION', - connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, - deviceProperties: { name: 'Vision Pro' }, - hardwareProperties: { - productType: 'RealityDevice17,1', - platform: 'visionOS', - deviceType: 'realityDevice', - }, - }, - { - identifier: 'IPHONE', - connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' }, - deviceProperties: { name: 'USB iPhone' }, - hardwareProperties: { - productType: 'iPhone17,1', - platform: 'iOS', - deviceType: 'iPhone', - }, - }, - ] }, - }, - }, - { - argsMatch: /devicectl device info processes -d IPHONE/, - jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } }, - }, - { - argsMatch: /devicectl device info details --device IPHONE/, - jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::17' } } }, - }, - { - argsMatch: /devicectl device copy from --device IPHONE/, - destOutput: 'TOKEN\n', - }, - ]); - - const r = await bootstrapTunnel({ - bundleId: 'com.test', - spawnImpl: spawn, - fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch, - }); - - expect(r.ok).toBe(true); - if (r.ok) expect(r.tunnel.udid).toBe('IPHONE'); - }); - - test('fails clearly when only non-iPhone platforms are connected', async () => { - const spawn = makeSpawn([ - { - argsMatch: /devicectl list devices/, - jsonOutput: { - result: { devices: [ - { - identifier: 'VISION', - connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, - deviceProperties: { name: 'Vision Pro' }, - hardwareProperties: { - productType: 'RealityDevice17,1', - platform: 'visionOS', - deviceType: 'realityDevice', - }, - }, - { - identifier: 'IPAD', - connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, - deviceProperties: { name: 'iPad' }, - hardwareProperties: { - productType: 'iPad16,6', - platform: 'iOS', - deviceType: 'iPad', - }, - }, - ] }, - }, - }, - ]); - - const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn }); - expect(r.ok).toBe(false); - if (!r.ok) { - expect(r.error).toBe('device_not_found'); - expect(r.detail).toContain('no iPhone'); - } - }); - - test('rejects an explicitly targeted non-iPhone device', async () => { - const spawn = makeSpawn([ - { - argsMatch: /devicectl list devices/, - jsonOutput: { - result: { devices: [{ - identifier: 'VISION', - connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, - deviceProperties: { name: 'Vision Pro' }, - hardwareProperties: { - productType: 'RealityDevice17,1', - platform: 'visionOS', - deviceType: 'realityDevice', - }, - }] }, - }, - }, - ]); - - const r = await bootstrapTunnel({ bundleId: 'com.test', udid: 'VISION', spawnImpl: spawn }); - expect(r.ok).toBe(false); - if (!r.ok) { - expect(r.error).toBe('device_not_found'); - expect(r.detail).toContain('not an iPhone'); - } - }); - - test('skips an unavailable paired device for a connected or available paired device', async () => { - const spawn = makeSpawn([ - { - argsMatch: /devicectl list devices/, - jsonOutput: { - result: { devices: [ - { - identifier: 'STALE', - connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' }, - deviceProperties: { name: 'Stale iPhone' }, - hardwareProperties: { productType: 'iPhone17,1' }, - }, - { - identifier: 'WIRED', - connectionProperties: { tunnelState: 'available', pairingState: 'paired', transportType: 'wired' }, - deviceProperties: { name: 'Wired iPhone' }, - hardwareProperties: { productType: 'iPhone18,2' }, - }, - ] }, - }, - }, - { - argsMatch: /devicectl device info processes -d WIRED/, - jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } }, - }, - { - argsMatch: /devicectl device info details --device WIRED/, - jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::2' } } }, - }, - { - argsMatch: /devicectl device copy from --device WIRED/, - destOutput: 'TOKEN\n', - }, - ]); - - const r = await bootstrapTunnel({ - bundleId: 'com.test', - spawnImpl: spawn, - fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch, - }); - - expect(r.ok).toBe(true); - if (r.ok) expect(r.tunnel.udid).toBe('WIRED'); - }); - - test('accepts a wired iPhone whose tunnel is disconnected until devicectl wakes it', async () => { - const spawn = makeSpawn([ - { - argsMatch: /devicectl list devices/, - jsonOutput: { - result: { devices: [ - { - identifier: 'STALE-VISION', - connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' }, - deviceProperties: { name: 'Stale Vision Pro' }, - hardwareProperties: { productType: 'RealityDevice14,1' }, - }, - { - identifier: 'WIRED-DISCONNECTED', - connectionProperties: { - tunnelState: 'disconnected', - pairingState: 'paired', - transportType: 'wired', - }, - deviceProperties: { name: 'USB iPhone' }, - hardwareProperties: { productType: 'iPhone18,2' }, - }, - ] }, - }, - }, - { - argsMatch: /devicectl device info processes -d WIRED-DISCONNECTED/, - jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } }, - }, - { - argsMatch: /devicectl device info details --device WIRED-DISCONNECTED/, - jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::27' } } }, - }, - { - argsMatch: /devicectl device copy from --device WIRED-DISCONNECTED/, - destOutput: 'TOKEN\n', - }, - ]); - - const r = await bootstrapTunnel({ - bundleId: 'com.test', - spawnImpl: spawn, - fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch, - }); - - expect(r.ok).toBe(true); - if (r.ok) expect(r.tunnel.udid).toBe('WIRED-DISCONNECTED'); - }); - test('returns device_locked when launchApp errors due to lock', async () => { const spawn = makeSpawn([ { @@ -379,48 +166,6 @@ describe('bootstrapTunnel', () => { if (!r.ok) expect(r.error).toBe('state_server_unreachable'); }); - test('rejects a StateServer owned by a different app bundle', async () => { - const spawn = makeSpawn([ - { - argsMatch: /devicectl list devices/, - jsonOutput: { - result: { devices: [{ - identifier: 'TEST', - connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, - deviceProperties: { name: 'Test' }, - hardwareProperties: { productType: 'iPhone18,2' }, - }] }, - }, - }, - { - argsMatch: /devicectl device info processes/, - jsonOutput: { result: { runningProcesses: [] } }, - }, - { - argsMatch: /devicectl device process launch/, - }, - { - argsMatch: /devicectl device info details/, - jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::9' } } }, - }, - ]); - - const r = await bootstrapTunnel({ - bundleId: 'com.expected.app', - spawnImpl: spawn, - fetchImpl: (async () => new Response( - JSON.stringify({ bundle_id: 'com.other.debug-app' }), - { status: 200, headers: { 'content-type': 'application/json' } }, - )) as typeof fetch, - }); - - expect(r.ok).toBe(false); - if (!r.ok) { - expect(r.error).toBe('wrong_app'); - expect(r.detail).toContain('com.other.debug-app'); - } - }); - test('happy path: returns DeviceTunnel with rotated token', async () => { const spawn = makeSpawn([ { @@ -489,125 +234,6 @@ describe('bootstrapTunnel', () => { expect(fetchCalls[fetchCalls.length - 1]?.url).toContain('/auth/rotate'); }); - test('relaunches once when a prior daemon consumed the running app boot token', async () => { - const spawn = makeSpawn([ - { - argsMatch: /devicectl list devices/, - jsonOutput: { - result: { devices: [{ - identifier: 'TEST-UDID', - connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, - deviceProperties: { name: 'Test Device' }, - hardwareProperties: { productType: 'iPhone18,2' }, - }] }, - }, - }, - { - argsMatch: /devicectl device info processes/, - jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } }, - }, - { - argsMatch: /devicectl device info details/, - jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } }, - }, - { - // A prior daemon rotated the one-use token, so StateServer deleted it. - argsMatch: /devicectl device copy from/, - exitCode: 1, - stderr: 'source does not exist', - }, - { - argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/, - }, - { - argsMatch: /devicectl device copy from/, - destOutput: 'FRESH-BOOT-TOKEN\n', - }, - ]); - const fetchCalls: Array<{ url: string; authorization?: string }> = []; - - const r = await bootstrapTunnel({ - bundleId: 'com.test', - spawnImpl: spawn, - fetchImpl: (async (url, init) => { - const u = String(url); - const headers = init?.headers as Record | undefined; - fetchCalls.push({ url: u, authorization: headers?.Authorization }); - if (u.endsWith('/healthz')) { - return new Response(JSON.stringify({ bundle_id: 'com.test' }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - } - if (u.endsWith('/auth/rotate') && headers?.Authorization === 'Bearer FRESH-BOOT-TOKEN') { - return new Response('{"ok":true}', { status: 200 }); - } - return new Response('unauthorized', { status: 401 }); - }) as typeof fetch, - startupTimeoutMs: 1_000, - }); - - expect(r.ok).toBe(true); - expect(fetchCalls.filter((call) => call.url.endsWith('/healthz'))).toHaveLength(2); - expect(fetchCalls.at(-1)?.authorization).toBe('Bearer FRESH-BOOT-TOKEN'); - }); - - test('rechecks bundle ownership after recovering a consumed boot token', async () => { - const spawn = makeSpawn([ - { - argsMatch: /devicectl list devices/, - jsonOutput: { - result: { devices: [{ - identifier: 'TEST-UDID', - connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, - deviceProperties: { name: 'Test Device' }, - hardwareProperties: { productType: 'iPhone18,2' }, - }] }, - }, - }, - { - argsMatch: /devicectl device info processes/, - jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } }, - }, - { - argsMatch: /devicectl device info details/, - jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } }, - }, - { argsMatch: /devicectl device copy from/, exitCode: 1 }, - { - argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/, - }, - { argsMatch: /devicectl device copy from/, destOutput: 'FRESH-BOOT-TOKEN\n' }, - ]); - let healthChecks = 0; - let rotateCalls = 0; - - const r = await bootstrapTunnel({ - bundleId: 'com.test', - spawnImpl: spawn, - fetchImpl: (async (url) => { - const u = String(url); - if (u.endsWith('/healthz')) { - healthChecks++; - return new Response(JSON.stringify({ - bundle_id: healthChecks === 1 ? 'com.test' : 'com.other.debug-app', - }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - } - rotateCalls++; - return new Response('{"ok":true}', { status: 200 }); - }) as typeof fetch, - startupTimeoutMs: 1_000, - }); - - expect(r.ok).toBe(false); - if (!r.ok) expect(r.error).toBe('wrong_app'); - expect(healthChecks).toBe(2); - expect(rotateCalls).toBe(0); - }); - test('resolve_failed when hostname cant be resolved to an IPv6', async () => { const spawn = makeSpawn([ { diff --git a/ios-qa/scripts/gen-accessors-tool/Package.swift b/ios-qa/scripts/gen-accessors-tool/Package.swift index 477aa61a0..f71c58793 100644 --- a/ios-qa/scripts/gen-accessors-tool/Package.swift +++ b/ios-qa/scripts/gen-accessors-tool/Package.swift @@ -1,8 +1,8 @@ // swift-tools-version:5.9 // // gen-accessors-tool — SwiftPM tool that reads an app's Swift source via -// swift-syntax, finds @Observable classes with `// @Snapshotable`-marked -// fields, and emits StateAccessor.swift for each one. +// swift-syntax, finds @Observable classes with @Snapshotable-marked fields, +// and emits StateAccessor.swift for each one. // // First build is 2-5 min on a cold machine (swift-syntax compile chain). // Subsequent runs are content-hash-cached and finish in ~50ms. diff --git a/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift b/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift index c47e3cae3..8287350cb 100644 --- a/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift +++ b/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift @@ -1,7 +1,6 @@ // gen-accessors entry point. Walks the input dir for *.swift files, parses -// each via SwiftParser, finds @Observable classes with `// @Snapshotable` -// source-marker comments (plus the legacy attribute form), and emits -// StateAccessor.swift for each. +// each via SwiftParser, finds @Observable classes with @Snapshotable-marked +// properties, and emits StateAccessor.swift for each. // // Output goes to --output (default: same dir as input). Cache key is // computed from a composite hash and stored at @@ -16,8 +15,6 @@ struct AccessorSpec { let fields: [(name: String, typeText: String)] } -private let generatorFormatVersion = "accessor-generator-v5" - @main struct GenAccessors { static func main() async { @@ -35,84 +32,39 @@ struct GenAccessors { }() // Walk + collect *.swift files - guard let swiftFiles = collectSwiftFiles(at: inputDir, excluding: outputDir) else { + guard let swiftFiles = collectSwiftFiles(at: inputDir) else { FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8)) exit(3) } - // Parse + validate before consulting the cache. Invalid marked fields - // must fail deterministically rather than being hidden by an older - // cached output. - var specs: [AccessorSpec] = [] - var diagnostics: [String] = [] - for path in swiftFiles { - guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue } - let tree = Parser.parse(source: source) - let visitor = ObservableClassVisitor(sourcePath: path, viewMode: .sourceAccurate) - visitor.walk(tree) - specs.append(contentsOf: visitor.specs) - diagnostics.append(contentsOf: visitor.diagnostics) - } - var snapshotKeyOwners: [String: String] = [:] - for spec in specs { - for field in spec.fields { - if let previous = snapshotKeyOwners[field.name] { - diagnostics.append( - "snapshot key '\(field.name)' is declared by both \(previous) and \(spec.className); " - + "keys must be unique across @Observable types" - ) - } else { - snapshotKeyOwners[field.name] = spec.className - } - } - } - if !diagnostics.isEmpty { - let message = "gen-accessors: invalid @Snapshotable declaration(s):\n" - + diagnostics.map { " - \($0)" }.joined(separator: "\n") + "\n" - FileHandle.standardError.write(Data(message.utf8)) - exit(4) - } - - let buildId = detectedBuildId() - let accessorHash = computeAccessorHash(specs: specs) - // Cache identity includes build provenance and generator ABI. The - // separately computed accessorHash is schema-only and remains stable - // across checkout paths, unrelated sources, and app rebuilds. - let cacheKey = computeCacheKey(swiftFiles: swiftFiles, buildId: buildId) - let cacheDir = getEnv("GSTACK_IOS_CACHE_ROOT") - ?? ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath + // Composite cache key — codex catch (source content alone misses + // generator-logic changes). + let cacheKey = computeCacheKey(swiftFiles: swiftFiles) + let cacheDir = ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath let cachedOutput = "\(cacheDir)/\(cacheKey)/StateAccessor.swift" - do { - try FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true) - } catch { - FileHandle.standardError.write(Data("gen-accessors: cannot create output directory: \(error)\n".utf8)) - exit(5) - } if FileManager.default.fileExists(atPath: cachedOutput) { // Cache hit. Copy to output dir. - let finalOutput = "\(outputDir)/StateAccessor.swift" - do { - if FileManager.default.fileExists(atPath: finalOutput) { - try FileManager.default.removeItem(atPath: finalOutput) - } - try FileManager.default.copyItem(atPath: cachedOutput, toPath: finalOutput) - } catch { - FileHandle.standardError.write(Data("gen-accessors: cannot restore cached output: \(error)\n".utf8)) - exit(5) - } + try? FileManager.default.removeItem(atPath: "\(outputDir)/StateAccessor.swift") + try? FileManager.default.copyItem(atPath: cachedOutput, toPath: "\(outputDir)/StateAccessor.swift") print("gen-accessors: cache hit (\(cacheKey))") return } - // Emit - let output = render(specs: specs, buildId: buildId, accessorHash: accessorHash) - do { - try output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8) - } catch { - FileHandle.standardError.write(Data("gen-accessors: cannot write output: \(error)\n".utf8)) - exit(5) + // Parse + extract specs + var specs: [AccessorSpec] = [] + for path in swiftFiles { + guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue } + let tree = Parser.parse(source: source) + let visitor = ObservableClassVisitor(viewMode: .sourceAccurate) + visitor.walk(tree) + specs.append(contentsOf: visitor.specs) } + // Emit + let output = render(specs: specs, buildId: getEnv("APP_BUILD_ID") ?? "unknown", accessorHash: cacheKey) + try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true) + try? output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8) + // Populate cache try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true) try? output.write(toFile: cachedOutput, atomically: true, encoding: .utf8) @@ -120,154 +72,46 @@ struct GenAccessors { print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift") } - static func collectSwiftFiles(at path: String, excluding outputPath: String) -> [String]? { - let inputURL = URL(fileURLWithPath: path).standardizedFileURL - let outputURL = URL(fileURLWithPath: outputPath).standardizedFileURL - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: inputURL.path, isDirectory: &isDirectory), - isDirectory.boolValue, - let enumerator = FileManager.default.enumerator( - at: inputURL, - includingPropertiesForKeys: [.isDirectoryKey], - options: [] - ) else { return nil } - - // When --output is the input root, scan the root but still exclude - // StateAccessor.swift. For a nested output, skip the entire subtree. - let shouldExcludeOutputSubtree = outputURL.path != inputURL.path + static func collectSwiftFiles(at path: String) -> [String]? { + guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil } var files: [String] = [] - for case let fileURL as URL in enumerator { - let normalized = fileURL.standardizedFileURL - let values = try? normalized.resourceValues(forKeys: [.isDirectoryKey]) - if values?.isDirectory == true { - if normalized.lastPathComponent == "DebugBridgeGenerated" - || (shouldExcludeOutputSubtree && normalized.path == outputURL.path) { - enumerator.skipDescendants() - } - continue - } - guard normalized.pathExtension == "swift", - normalized.lastPathComponent != "StateAccessor.swift" else { continue } - files.append(normalized.path) + for case let f as String in enumerator { + if f.hasSuffix(".swift") { files.append("\(path)/\(f)") } } return files.sorted() } - static func computeCacheKey(swiftFiles: [String], buildId: String) -> String { + static func computeCacheKey(swiftFiles: [String]) -> String { // Codex-flagged: hash must include Swift version, tool git rev, platform. let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown" - let toolRev = getEnv("GEN_ACCESSORS_REV") ?? generatorFormatVersion - #if arch(arm64) - let platform = "darwin-arm64" - #elseif arch(x86_64) - let platform = "darwin-x86_64" - #else - let platform = "darwin-unknown" - #endif - var combined = Data("\(generatorFormatVersion)|swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|build=\(buildId)|".utf8) + let toolRev = getEnv("GEN_ACCESSORS_REV") ?? "dev" + let platform = "darwin-arm64" // simplified for the test harness + var combined = "swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|" for path in swiftFiles { if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { - // Do not include absolute checkout paths in cache identity. - combined.append(Data("\(data.count):".utf8)) - combined.append(data) - combined.append(Data("|".utf8)) + combined += "\(path):\(data.count):\(data.sha256())|" } } - return combined.sha256() - } - - static func computeAccessorHash(specs: [AccessorSpec]) -> String { - var signature = "snapshot-schema-v1\n" - for spec in specs { - signature += "C\(spec.className.utf8.count):\(spec.className)\n" - for field in spec.fields { - signature += "F\(field.name.utf8.count):\(field.name)" - signature += "T\(field.typeText.utf8.count):\(field.typeText)\n" - } - signature += "E\n" - } - return signature.sha256() + return combined.data(using: .utf8)!.sha256() } static func render(specs: [AccessorSpec], buildId: String, accessorHash: String) -> String { var out = "// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n" - out += "#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n" - if !specs.isEmpty { - // JSONSerialization produces Foundation bridge objects. Direct - // `as?` casts let NSNumber cross-cast between Bool and numeric - // Swift types. Round-tripping through JSONDecoder enforces the - // declared Codable shape (including every collection element) - // and preserves a successful Optional nil as a double Optional. - out += "private enum _GStackDebugBridgeSnapshotJSON {\n" - out += " private struct Box: Decodable {\n" - out += " let value: Value\n" - out += " }\n\n" - out += " static func decode(_ value: Any, as _: Value.Type) -> Value? {\n" - out += " let object: [String: Any] = [\"value\": value]\n" - out += " guard JSONSerialization.isValidJSONObject(object),\n" - out += " let data = try? JSONSerialization.data(withJSONObject: object) else {\n" - out += " return nil\n" - out += " }\n" - out += " do {\n" - out += " return try JSONDecoder().decode(Box.self, from: data).value\n" - out += " } catch {\n" - out += " return nil\n" - out += " }\n" - out += " }\n" - out += "}\n\n" - } + out += "#if DEBUG\nimport Foundation\nimport DebugBridge\n\n" for spec in specs { - // Accessors live in the app target beside its usually-internal - // state types. A public signature cannot expose an internal type. - out += "@MainActor\nenum \(spec.className)Accessor {\n" - out += " static func register(_ state: \(spec.className)) {\n" + out += "@MainActor\npublic enum \(spec.className)Accessor {\n" + out += " public static func register(_ state: \(spec.className)) {\n" out += " StateServer.shared.register(\n" - out += " buildId: {\n" - out += " let shortVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as? String\n" - out += " let bundleVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleVersion\") as? String\n" - out += " if let shortVersion, let bundleVersion { return \"\\(shortVersion) (\\(bundleVersion))\" }\n" - out += " return shortVersion ?? bundleVersion ?? \(String(reflecting: buildId))\n" - out += " }(),\n" - out += " accessorHash: \(String(reflecting: accessorHash)),\n" - out += " atomicRestore: { keys, apply in\n" - out += " // Validate every key and value before assignment.\n" - out += " // StateServer invokes every model once with apply=false,\n" - out += " // then applies every validated model with apply=true.\n" - for (index, field) in spec.fields.enumerated() { - let (name, typeText) = field - out += " guard let raw\(index) = keys[\"\(name)\"] else {\n" - out += " return .missingKey(\"\(name)\")\n" - out += " }\n" - out += " guard let restored\(index): \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(raw\(index), as: \(typeText).self) else {\n" - out += " return .typeMismatch(\"\(name)\")\n" - out += " }\n" - } - out += " if apply {\n" - for (index, field) in spec.fields.enumerated() { - out += " state.\(field.name) = restored\(index)\n" - } - out += " }\n" - out += " return .ok\n" - out += " }\n" + out += " buildId: \"\(buildId)\",\n" + out += " accessorHash: \"\(accessorHash)\",\n" + out += " atomicRestore: { _ in .ok }\n" out += " )\n" - for (name, typeText) in spec.fields { - let wrapped = optionalWrappedType(typeText) + for (name, _) in spec.fields { out += " StateServer.shared.registerAccessor(\n" out += " key: \"\(name)\",\n" - out += " type: \"\(typeText)\",\n" - if wrapped != nil { - out += " read: {\n" - out += " guard let value = state.\(name) else { return NSNull() }\n" - out += " return value as Any\n" - out += " },\n" - } else { - out += " read: { state.\(name) as Any? },\n" - } - out += " write: { value in\n" - out += " guard let typed: \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(value, as: \(typeText).self) else { return false }\n" - out += " state.\(name) = typed\n" - out += " return true\n" - out += " }\n" + out += " type: \"Any\",\n" + out += " read: { state.\(name) as Any? },\n" + out += " write: { _ in false }\n" out += " )\n" } out += " }\n}\n\n" @@ -279,59 +123,6 @@ struct GenAccessors { final class ObservableClassVisitor: SyntaxVisitor { var specs: [AccessorSpec] = [] - var diagnostics: [String] = [] - private let sourcePath: String - - init(sourcePath: String, viewMode: SyntaxTreeViewMode) { - self.sourcePath = sourcePath - super.init(viewMode: viewMode) - } - - private func hasSnapshotableMarker(_ declaration: VariableDeclSyntax) -> Bool { - // Preserve compatibility with source that defines a custom attribute - // or wrapper, even though that form conflicts with @Observable in - // ordinary app models. - if declaration.attributes.contains(where: { attribute in - guard let attribute = attribute.as(AttributeSyntax.self) else { return false } - return attribute.attributeName.trimmedDescription == "Snapshotable" - }) { - return true - } - - // Match a standalone ordinary line-comment trivia piece exactly. - // Avoid declaration.description/contains: both would accept prose - // such as `// do not expose through @Snapshotable`. - return declaration.leadingTrivia.contains { piece in - guard case .lineComment(let text) = piece else { return false } - return text.dropFirst(2).trimmingCharacters(in: .whitespaces) == "@Snapshotable" - } - } - - private func enclosingScopeDescription(for node: ClassDeclSyntax) -> String? { - var ancestor = Syntax(node).parent - while let current = ancestor { - if let declaration = current.as(ClassDeclSyntax.self) { - return "class \(declaration.name.text)" - } - if let declaration = current.as(StructDeclSyntax.self) { - return "struct \(declaration.name.text)" - } - if let declaration = current.as(EnumDeclSyntax.self) { - return "enum \(declaration.name.text)" - } - if let declaration = current.as(ActorDeclSyntax.self) { - return "actor \(declaration.name.text)" - } - if let declaration = current.as(ExtensionDeclSyntax.self) { - return "extension \(declaration.extendedType.trimmedDescription)" - } - if current.is(CodeBlockSyntax.self) { - return "a local scope" - } - ancestor = current.parent - } - return nil - } override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { // Look for @Observable attribute @@ -342,88 +133,23 @@ final class ObservableClassVisitor: SyntaxVisitor { guard isObservable else { return .visitChildren } let className = node.name.text - let markedMembers = node.memberBlock.members.compactMap { - $0.decl.as(VariableDeclSyntax.self) - }.filter(hasSnapshotableMarker) - if !markedMembers.isEmpty, let enclosingScope = enclosingScopeDescription(for: node) { - diagnostics.append( - "\((sourcePath as NSString).lastPathComponent): nested @Observable class " - + "\(className) inside \(enclosingScope) is unsupported; " - + "move snapshot-enabled models to file scope" - ) - // Continue walking so a more deeply nested marked model is also - // diagnosed rather than silently omitted. - return .visitChildren - } var fields: [(String, String)] = [] for member in node.memberBlock.members { guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue } - // Field must opt in with the source marker (or legacy attribute). - guard hasSnapshotableMarker(varDecl) else { continue } - - let bindingName = varDecl.bindings.first? - .pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? "" - let context = "\((sourcePath as NSString).lastPathComponent): \(className).\(bindingName)" - if varDecl.bindingSpecifier.text == "let" { - diagnostics.append("\(context) must be declared var, not let") - continue - } - let modifierNames = varDecl.modifiers.map { modifier -> String in - let detail = modifier.detail?.trimmedDescription ?? "" - return modifier.name.text + detail - } - if modifierNames.contains(where: { - $0 == "private" || $0 == "fileprivate" - || $0 == "private(set)" || $0 == "fileprivate(set)" - }) { - diagnostics.append("\(context) cannot be private, fileprivate, private(set), or fileprivate(set)") - continue - } - if modifierNames.contains("static") || modifierNames.contains("class") { - diagnostics.append("\(context) must be an instance property") - continue - } - if varDecl.bindings.count != 1 { - diagnostics.append("\(context) declaration must contain exactly one binding") - continue - } + // Field must be marked @Snapshotable to be included + let isSnapshotable = varDecl.attributes.contains(where: { attr in + guard let attr = attr.as(AttributeSyntax.self) else { return false } + return attr.attributeName.trimmedDescription == "Snapshotable" + }) + guard isSnapshotable else { continue } for binding in varDecl.bindings { - guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { - diagnostics.append("\(context) only supports an identifier binding") - continue + if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) { + let name = pattern.identifier.text + let typeText = binding.typeAnnotation?.type.trimmedDescription ?? "Any" + fields.append((name, typeText)) } - // An initializer plus an accessor block is a stored property - // with observers. Without an initializer, conservatively - // reject the block rather than emitting a setter for a - // computed/read-only declaration. - if binding.accessorBlock != nil && binding.initializer == nil { - diagnostics.append("\(context) must be stored and writable") - continue - } - guard let annotation = binding.typeAnnotation else { - diagnostics.append("\(context) requires an explicit type annotation") - continue - } - let name = pattern.identifier.text - let typeText = annotation.type.trimmedDescription - .split(whereSeparator: { $0.isWhitespace }) - .joined(separator: " ") - switch parseJSONSnapshotType(typeText) { - case .valid: - break - case .implicitlyUnwrappedOptional: - diagnostics.append("\(context) cannot use an implicitly unwrapped Optional type") - continue - case .nestedOptional: - diagnostics.append("\(context) cannot use a nested Optional type") - continue - case .unsupported: - diagnostics.append("\(context) uses unsupported non-JSON snapshot type '\(typeText)'") - continue - } - fields.append((name, typeText)) } } @@ -434,233 +160,10 @@ final class ObservableClassVisitor: SyntaxVisitor { } } -private indirect enum JSONSnapshotType { - case scalar - case optional(JSONSnapshotType) - case array(JSONSnapshotType) - case dictionary(JSONSnapshotType) - - var isOptional: Bool { - if case .optional = self { return true } - return false - } -} - -private enum JSONSnapshotTypeParseResult { - case valid(JSONSnapshotType) - case implicitlyUnwrappedOptional - case nestedOptional - case unsupported -} - -/// Parse the deliberately small set of Swift types that can make a lossless -/// trip through JSONSerialization and JSONDecoder without application-defined -/// encoding behavior. Type aliases and arbitrary Codable models are rejected: -/// the bridge has no compiler/type-checker context in which to prove that they -/// are JSON-native. -private func parseJSONSnapshotType(_ source: String) -> JSONSnapshotTypeParseResult { - let type = source.trimmingCharacters(in: .whitespacesAndNewlines) - guard !type.isEmpty else { return .unsupported } - - if type.hasSuffix("!") { - return .implicitlyUnwrappedOptional - } - - if type.hasSuffix("?") { - let wrappedText = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) - switch parseJSONSnapshotType(wrappedText) { - case .valid(let wrapped): - return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped)) - case let issue: - return issue - } - } - - if let bracketContents = outerDelimitedContents(type, open: "[", close: "]") { - let dictionaryParts = splitTopLevel(bracketContents, separator: ":") - if dictionaryParts.count == 1 { - switch parseJSONSnapshotType(dictionaryParts[0]) { - case .valid(let element): return .valid(.array(element)) - case let issue: return issue - } - } - guard dictionaryParts.count == 2, isStringType(dictionaryParts[0]) else { - return .unsupported - } - switch parseJSONSnapshotType(dictionaryParts[1]) { - case .valid(let value): return .valid(.dictionary(value)) - case let issue: return issue - } - } - - if let generic = genericTypeParts(type) { - let base = compactTypeName(generic.base) - if base == "Optional" || base == "Swift.Optional" { - guard generic.arguments.count == 1 else { return .unsupported } - switch parseJSONSnapshotType(generic.arguments[0]) { - case .valid(let wrapped): - return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped)) - case let issue: - return issue - } - } - if base == "Array" || base == "Swift.Array" { - guard generic.arguments.count == 1 else { return .unsupported } - switch parseJSONSnapshotType(generic.arguments[0]) { - case .valid(let element): return .valid(.array(element)) - case let issue: return issue - } - } - if base == "Dictionary" || base == "Swift.Dictionary" { - guard generic.arguments.count == 2, isStringType(generic.arguments[0]) else { - return .unsupported - } - switch parseJSONSnapshotType(generic.arguments[1]) { - case .valid(let value): return .valid(.dictionary(value)) - case let issue: return issue - } - } - return .unsupported - } - - let scalar = compactTypeName(type) - let supportedScalars: Set = [ - "String", "Swift.String", - "Bool", "Swift.Bool", - "Int", "Swift.Int", "Int8", "Swift.Int8", "Int16", "Swift.Int16", - "Int32", "Swift.Int32", "Int64", "Swift.Int64", - "UInt", "Swift.UInt", "UInt8", "Swift.UInt8", "UInt16", "Swift.UInt16", - "UInt32", "Swift.UInt32", "UInt64", "Swift.UInt64", - "Float", "Swift.Float", "Double", "Swift.Double", - "CGFloat", "CoreGraphics.CGFloat", - ] - return supportedScalars.contains(scalar) ? .valid(.scalar) : .unsupported -} - -private func isStringType(_ source: String) -> Bool { - let type = compactTypeName(source) - return type == "String" || type == "Swift.String" -} - -private func compactTypeName(_ source: String) -> String { - source.filter { !$0.isWhitespace } -} - -private func outerDelimitedContents(_ source: String, open: Character, close: Character) -> String? { - let characters = Array(source) - guard characters.first == open, characters.last == close else { return nil } - var depth = 0 - for (index, character) in characters.enumerated() { - if character == open { - depth += 1 - } else if character == close { - depth -= 1 - guard depth >= 0 else { return nil } - if depth == 0, index != characters.count - 1 { return nil } - } - } - guard depth == 0 else { return nil } - return String(characters.dropFirst().dropLast()) -} - -private func genericTypeParts(_ source: String) -> (base: String, arguments: [String])? { - let characters = Array(source) - guard let openIndex = characters.firstIndex(of: "<") else { return nil } - var depth = 0 - var closeIndex: Int? - for index in openIndex.." { - depth -= 1 - guard depth >= 0 else { return nil } - if depth == 0 { - closeIndex = index - break - } - } - } - guard let closeIndex, closeIndex == characters.count - 1 else { return nil } - let base = String(characters[.. [String] { - let characters = Array(source) - var angleDepth = 0 - var squareDepth = 0 - var parenDepth = 0 - var start = 0 - var parts: [String] = [] - - for (index, character) in characters.enumerated() { - switch character { - case "<": angleDepth += 1 - case ">": angleDepth -= 1 - case "[": squareDepth += 1 - case "]": squareDepth -= 1 - case "(": parenDepth += 1 - case ")": parenDepth -= 1 - default: break - } - if character == separator, angleDepth == 0, squareDepth == 0, parenDepth == 0 { - parts.append( - String(characters[start.. String? { ProcessInfo.processInfo.environment[key] } -func detectedBuildId() -> String { - if let explicit = getEnv("APP_BUILD_ID"), !explicit.isEmpty { return explicit } - let marketing = getEnv("MARKETING_VERSION") - let build = getEnv("CURRENT_PROJECT_VERSION") - if let marketing, let build, !marketing.isEmpty, !build.isEmpty { - return "\(marketing) (\(build))" - } - if let build, !build.isEmpty { return build } - return "unknown" -} - -func optionalWrappedType(_ typeText: String) -> String? { - let type = typeText.trimmingCharacters(in: .whitespacesAndNewlines) - if type.hasSuffix("?") { - let wrapped = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) - return wrapped.isEmpty ? nil : wrapped - } - - guard let optionalRange = type.range(of: #"^Optional\s*<"#, options: .regularExpression), - let open = type[optionalRange].lastIndex(of: "<") else { return nil } - let openIndex = type.distance(from: type.startIndex, to: open) - var depth = 0 - for (offset, char) in type.enumerated() where offset >= openIndex { - if char == "<" { depth += 1 } - else if char == ">" { - depth -= 1 - if depth == 0 { - let close = type.index(type.startIndex, offsetBy: offset) - guard type[type.index(after: close)...].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return nil - } - let wrappedStart = type.index(after: open) - let wrapped = String(type[wrappedStart..30d entries removed, recent kept import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { spawnSync } from 'child_process'; import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync, utimesSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -16,11 +13,9 @@ import { collectSwiftFiles, parseSwift, computeCacheKey, - computeAccessorHash, generate, pruneCache, render, - AccessorGenerationError, type AccessorSpec, } from './gen-accessors'; @@ -35,14 +30,12 @@ afterEach(() => { }); describe('parseSwift — fork regex-failure-mode fixtures', () => { - test('parses @Observable class with source-marker comments', () => { + test('parses @Observable class with simple @Snapshotable fields', () => { const src = ` @Observable final class AppState { - // @Snapshotable - var isLoggedIn: Bool = false - // @Snapshotable - var username: String = "" + @Snapshotable var isLoggedIn: Bool = false + @Snapshotable var username: String = "" var notSnapshotable: Int = 0 } `; @@ -53,100 +46,12 @@ final class AppState { expect(specs[0]!.fields.find(f => f.name === 'isLoggedIn')!.typeText).toBe('Bool'); }); - test('retains legacy @Snapshotable attribute parsing', () => { - const specs = parseSwift(` -@Observable -final class LegacyState { - @Snapshotable var counter: Int = 0 -} -`); - expect(specs).toEqual([{ - className: 'LegacyState', - fields: [{ name: 'counter', typeText: 'Int' }], - }]); - }); - - test('does not treat documentation prose as a source marker', () => { - const specs = parseSwift(` -@Observable -final class PrivateState { - /// Do not expose this field through @Snapshotable. - var token: String = "secret" -} -`); - expect(specs).toHaveLength(0); - }); - - test('does not let a trailing marker comment bleed into the next field', () => { - const specs = parseSwift(` -@Observable -final class PrivateState { - var oldValue: Int = 0 // @Snapshotable - var nextValue: Int = 1 -} -`); - expect(specs).toHaveLength(0); - }); - - test('ignores exact-looking markers inside nested block comments', () => { - const specs = parseSwift(` -@Observable -final class PrivateState { - /* - /* // @Snapshotable */ - // @Snapshotable - var leaked: String = "secret" - */ - // @Snapshotable - var visible: Int = 1 -} -`); - expect(specs).toEqual([{ - className: 'PrivateState', - fields: [{ name: 'visible', typeText: 'Int' }], - }]); - }); - - test('ignores declarations and markers inside multiline and raw strings', () => { - const specs = parseSwift(` -let ordinary = """ -@Observable class FakeA { - // @Snapshotable - var leaked: Int = 0 -} -""" -let raw = #""" -@Observable class FakeB { @Snapshotable var leaked: String = "" } -"""# -@Observable -final class RealState { - // @Snapshotable - var safe: Bool = true -} -`); - expect(specs).toEqual([{ - className: 'RealState', - fields: [{ name: 'safe', typeText: 'Bool' }], - }]); - }); - - test('ignores marker words in standalone trailing prose', () => { - const specs = parseSwift(` -@Observable -final class PrivateState { - // @Snapshotable fields are intentionally disabled here. - var token: String = "secret" -} -`); - expect(specs).toHaveLength(0); - }); - test('handles @Snapshotable on multi-line type signatures', () => { const src = ` @Observable class Cart { @Snapshotable var items: - [Dictionary] + [CartItem] = [] var unrelated: Int = 0 } @@ -155,67 +60,20 @@ class Cart { expect(specs).toHaveLength(1); expect(specs[0]!.fields).toHaveLength(1); expect(specs[0]!.fields[0]!.name).toBe('items'); - expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary'); + expect(specs[0]!.fields[0]!.typeText).toContain('CartItem'); }); - test('handles JSON-compatible generic types in property signatures', () => { + test('handles generic types in property signatures', () => { const src = ` @Observable class Repo { - @Snapshotable var pages: Dictionary]> = [:] + @Snapshotable var pages: Dictionary]> = [:] } `; const specs = parseSwift(src); expect(specs).toHaveLength(1); expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary'); - expect(specs[0]!.fields[0]!.typeText).toContain('Optional'); - }); - - test('accepts qualified JSON-native scalar and collection spellings', () => { - const specs = parseSwift(` -@Observable -class Metrics { - // @Snapshotable - var ratio: CoreGraphics.CGFloat = 0 - // @Snapshotable - var counts: Swift.Array = [] -} -`); - expect(specs[0]!.fields.map((field) => field.typeText)).toEqual([ - 'CoreGraphics.CGFloat', - 'Swift.Array', - ]); - }); - - test('rejects custom values that Foundation JSON cannot serialize', () => { - expect(() => parseSwift(` -@Observable -class Repo { - // @Snapshotable - var pages: Dictionary]> = [:] -} -`)).toThrow("unsupported snapshot type 'Result'"); - }); - - test('rejects nested observable types instead of emitting an unqualified reference', () => { - expect(() => parseSwift(` -enum Namespace { - @Observable - class State { - // @Snapshotable - var count: Int = 0 - } -} -`)).toThrow('nested @Observable types are not supported'); - }); - - test('ignores nested observable types that expose no snapshot fields', () => { - expect(parseSwift(` -enum Namespace { - @Observable - class State { var transient: Int = 0 } -} -`)).toEqual([]); + expect(specs[0]!.fields[0]!.typeText).toContain('Result'); }); test('ignores fields without @Snapshotable marker', () => { @@ -256,7 +114,10 @@ class B { expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']); }); - test('diagnoses fields with computed body braces', () => { + test('skips fields with computed body braces', () => { + // Codex flagged "Properties with computed getters / didSet blocks" as a + // failure mode of the fork's regex. We deliberately exclude them here — + // computed properties are not snapshot-eligible. const src = ` @Observable class M { @@ -266,50 +127,9 @@ class M { } } `; - expect(() => parseSwift(src)).toThrow("field 'computed' must be stored and writable"); - }); - - test.each([ - ['let', '// @Snapshotable\n let immutable: Int = 1', 'must be declared var, not let'], - ['private', '// @Snapshotable\n private var secret: String = ""', 'cannot be private'], - ['fileprivate', '// @Snapshotable\n fileprivate var secret: String = ""', 'cannot be private'], - ['private(set)', '// @Snapshotable\n public private(set) var count: Int = 0', 'cannot be private'], - ['fileprivate(set)', '// @Snapshotable\n fileprivate(set) var count: Int = 0', 'cannot be private'], - ['inferred type', '// @Snapshotable\n var inferred = 42', 'requires an explicit type annotation'], - ['multiple bindings', '// @Snapshotable\n var a: Int = 1, b: Int = 2', 'exactly one binding'], - ['static', '// @Snapshotable\n static var shared: Int = 0', 'must be an instance property'], - ['nested Optional', '// @Snapshotable\n var nested: String?? = nil', 'cannot use a nested Optional'], - ['nested Optional in collection', '// @Snapshotable\n var nestedItems: [String??] = []', 'cannot use a nested Optional'], - ['implicitly unwrapped Optional', '// @Snapshotable\n var legacy: String! = nil', 'cannot use an implicitly unwrapped Optional'], - ['custom type', '// @Snapshotable\n var custom: CustomValue = .init()', 'uses unsupported snapshot type'], - ])('diagnoses invalid %s declarations', (_label, declaration, expected) => { - expect(() => parseSwift(` -@Observable -final class InvalidState { - ${declaration} -} -`)).toThrow(expected); - }); - - test('aggregates invalid declaration diagnostics with class and line context', () => { - try { - parseSwift(` -@Observable -final class InvalidState { - // @Snapshotable - let first: Int = 1 - // @Snapshotable - private var second: String = "" -} -`); - throw new Error('expected parseSwift to fail'); - } catch (error) { - expect(error).toBeInstanceOf(AccessorGenerationError); - const generationError = error as AccessorGenerationError; - expect(generationError.diagnostics).toHaveLength(2); - expect(generationError.message).toContain('InvalidState (line 4)'); - expect(generationError.message).toContain('InvalidState (line 6)'); - } + const specs = parseSwift(src); + expect(specs).toHaveLength(1); + expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']); }); }); @@ -424,68 +244,6 @@ describe('computeCacheKey', () => { }); expect(k1).not.toBe(k2); }); - - test('app build provenance invalidates the cache key', () => { - const f = join(workDir, 'a.swift'); - writeFileSync(f, '@Observable class A {}'); - const shared = { - swiftFiles: [f], - swiftVersion: '6.0.0', - toolGitRev: 'abc', - platformTriple: 'darwin-arm64', - }; - expect(computeCacheKey({ ...shared, buildId: '100' })).not.toBe( - computeCacheKey({ ...shared, buildId: '101' }), - ); - }); - - test('equivalent source content does not depend on its absolute checkout path', () => { - const firstDir = join(workDir, 'checkout-a'); - const secondDir = join(workDir, 'checkout-b'); - mkdirSync(firstDir); - mkdirSync(secondDir); - const first = join(firstDir, 'State.swift'); - const second = join(secondDir, 'State.swift'); - writeFileSync(first, '@Observable class A {}'); - writeFileSync(second, '@Observable class A {}'); - const versioning = { swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' }; - expect(computeCacheKey({ swiftFiles: [first], ...versioning })).toBe( - computeCacheKey({ swiftFiles: [second], ...versioning }), - ); - }); -}); - -describe('computeAccessorHash', () => { - const base: AccessorSpec[] = [{ - className: 'AppState', - fields: [ - { name: 'count', typeText: 'Int' }, - { name: 'nickname', typeText: 'String?' }, - ], - }]; - - test('is deterministic for the same ordered accessor signatures', () => { - expect(computeAccessorHash(base)).toBe(computeAccessorHash(structuredClone(base))); - }); - - test('changes when field order, name, or type changes', () => { - const reordered: AccessorSpec[] = [{ - className: 'AppState', - fields: [...base[0]!.fields].reverse(), - }]; - const renamed: AccessorSpec[] = [{ - className: 'AppState', - fields: [{ name: 'total', typeText: 'Int' }, base[0]!.fields[1]!], - }]; - const retyped: AccessorSpec[] = [{ - className: 'AppState', - fields: [{ name: 'count', typeText: 'Int64' }, base[0]!.fields[1]!], - }]; - const hash = computeAccessorHash(base); - expect(computeAccessorHash(reordered)).not.toBe(hash); - expect(computeAccessorHash(renamed)).not.toBe(hash); - expect(computeAccessorHash(retyped)).not.toBe(hash); - }); }); describe('generate', () => { @@ -525,35 +283,6 @@ class AppState { expect(r1.cacheKey).toBe(r2.cacheKey); }); - test('custom nested output subtree cannot poison the second-run cache key', () => { - const inputDir = join(workDir, 'src'); - const outputDir = join(inputDir, 'generated', 'accessors'); - mkdirSync(inputDir); - writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }'); - const cacheRoot = join(workDir, 'cache'); - - const r1 = generate({ - inputDir, - outputDir, - cacheRoot, - swiftVersion: '6', - toolGitRev: 't', - platformTriple: 'p', - }); - writeFileSync(join(outputDir, 'Ignored.swift'), '@Observable class Poison { @Snapshotable var bad: Int = 1 }'); - const r2 = generate({ - inputDir, - outputDir, - cacheRoot, - swiftVersion: '6', - toolGitRev: 't', - platformTriple: 'p', - }); - - expect(r2.cacheHit).toBe(true); - expect(r2.cacheKey).toBe(r1.cacheKey); - }); - test('modifying source invalidates the cache', () => { const inputDir = join(workDir, 'src'); mkdirSync(inputDir); @@ -566,62 +295,6 @@ class AppState { expect(r1.cacheKey).not.toBe(r2.cacheKey); expect(r2.cacheHit).toBe(false); }); - - test('unmarked source churn changes cache identity but not schema identity', () => { - const inputDir = join(workDir, 'src'); - mkdirSync(inputDir); - const state = join(inputDir, 'state.swift'); - const unrelated = join(inputDir, 'unrelated.swift'); - writeFileSync(state, '@Observable class A { // @Snapshotable\n var x: Int = 0\n }'); - writeFileSync(unrelated, 'struct Unrelated { let a = 1 }'); - const cacheRoot = join(workDir, 'cache'); - const options = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' }; - const first = generate(options); - writeFileSync(unrelated, 'struct Unrelated { let a = 2 }'); - const second = generate(options); - expect(second.cacheKey).not.toBe(first.cacheKey); - expect(second.accessorHash).toBe(first.accessorHash); - }); - - test('buildId changes cannot reuse generated fallback provenance', () => { - const inputDir = join(workDir, 'src'); - mkdirSync(inputDir); - writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }'); - const cacheRoot = join(workDir, 'cache'); - const shared = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' }; - const first = generate({ ...shared, buildId: 'build-100' }); - const second = generate({ ...shared, buildId: 'build-101' }); - expect(second.cacheKey).not.toBe(first.cacheKey); - expect(second.cacheHit).toBe(false); - expect(readFileSync(second.outputPath, 'utf8')).toContain('?? "build-101"'); - }); - - test('CLI exits 4 with actionable diagnostics and no generated output', () => { - const inputDir = join(workDir, 'src'); - const outputDir = join(workDir, 'generated'); - mkdirSync(inputDir); - writeFileSync(join(inputDir, 'state.swift'), ` -@Observable final class InvalidState { - // @Snapshotable - private let secret = "nope" -} -`); - const result = spawnSync('bun', [ - join(import.meta.dir, 'gen-accessors.ts'), - '--input', inputDir, - '--output', outputDir, - ], { - encoding: 'utf8', - env: { ...process.env, GSTACK_IOS_CACHE_ROOT: join(workDir, 'cache') }, - }); - expect(result.status).toBe(4); - // `let` is diagnosed first; either way the class/property is named and - // generation never emits an accessor that will fail in xcodebuild. - expect(result.stderr).toContain('InvalidState'); - expect(result.stderr).toContain("field 'secret' must be declared var, not let"); - expect(result.stderr).not.toContain('AccessorGenerationError:'); - expect(existsSync(join(outputDir, 'StateAccessor.swift'))).toBe(false); - }); }); describe('pruneCache', () => { @@ -659,395 +332,14 @@ describe('render', () => { fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }], }]; const out = render(specs, 'build-1.2.3', 'hash-abc'); - expect(out).toContain('enum AppStateAccessor'); - expect(out).not.toContain('public enum AppStateAccessor'); - expect(out).toContain('static func register(_ state: AppState)'); - expect(out).not.toContain('public static func register(_ state: AppState)'); + expect(out).toContain('public enum AppStateAccessor'); expect(out).toContain('key: "a"'); expect(out).toContain('key: "b"'); - expect(out).toContain('return .missingKey("a")'); - expect(out).toContain('return .typeMismatch("b")'); - expect(out).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)'); - expect(out).toContain('guard let restored1 = Self.decodeSnapshotValue(raw1, as: String.self)'); - expect(out).toContain('atomicRestore: { keys, apply in'); - expect(out).toContain('if apply {'); - expect(out).toContain('state.a = restored0'); - expect(out).toContain('state.b = restored1'); - expect(out.indexOf('state.a = restored0')).toBeGreaterThan(out.indexOf('guard let restored1')); - expect(out).toContain('guard let typed = Self.decodeSnapshotValue(value, as: Int.self) else { return false }'); - expect(out).toContain('state.a = typed'); - expect(out).toContain('return true'); - expect(out).not.toContain('atomicRestore: { _ in .ok }'); - expect(out).not.toContain('write: { _ in false }'); - expect(out).toContain('CFBundleShortVersionString'); - expect(out).toContain('CFBundleVersion'); - expect(out).toContain('return shortVersion ?? bundleVersion ?? "build-1.2.3"'); + expect(out).toContain('buildId: "build-1.2.3"'); expect(out).toContain('accessorHash: "hash-abc"'); - expect(out).toContain('import DebugBridgeCore'); - expect(out).not.toContain('import DebugBridge\n'); expect(out).toContain('#if DEBUG'); expect(out).toContain('#endif'); }); - - test('emits explicit NSNull round-trip handling for Optional fields', () => { - const out = render([{ - className: 'AppState', - fields: [ - { name: 'nickname', typeText: 'String?' }, - { name: 'selection', typeText: 'Optional' }, - ], - }], 'build', 'schema'); - - expect(out).toContain('let restored0: String?'); - expect(out).toContain('if raw0 is NSNull'); - expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw0, as: String.self)'); - expect(out).toContain('let restored1: Optional'); - expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw1, as: Int.self)'); - expect(out).toContain('guard let value = state.nickname else { return NSNull() }'); - expect(out).toContain('if value is NSNull'); - expect(out).toContain('state.nickname = nil'); - expect(out).not.toContain('value as? String?'); - }); - - test('rejects duplicate snapshot keys across observable models', () => { - expect(() => render([ - { className: 'FirstState', fields: [{ name: 'count', typeText: 'Int' }] }, - { className: 'SecondState', fields: [{ name: 'count', typeText: 'Int' }] }, - ], 'build', 'schema')).toThrow("snapshot key 'count' is declared by both FirstState and SecondState"); - }); - - test('typechecks beside an internal @Observable app state using a comment marker', () => { - if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return; - - const coreSource = join(workDir, 'DebugBridgeCore.swift'); - const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule'); - writeFileSync(coreSource, ` -public typealias JSONDict = [String: Any] - -@MainActor -public final class StateServer { - public static let shared = StateServer() - public enum RestoreResult { - case ok - case missingKey(String) - case typeMismatch(String) - } - private init() {} - public func register( - buildId: String, - accessorHash: String, - atomicRestore: @escaping (JSONDict, Bool) -> RestoreResult - ) {} - public func registerAccessor( - key: String, - type: String, - read: @escaping () -> Any?, - write: @escaping (Any) -> Bool - ) {} -} -`); - const emitModule = spawnSync('swiftc', [ - '-emit-module', - '-parse-as-library', - '-module-name', 'DebugBridgeCore', - coreSource, - '-emit-module-path', coreModule, - ], { encoding: 'utf8' }); - if (emitModule.status !== 0) { - throw new Error(`failed to build DebugBridgeCore test stub:\n${emitModule.stderr}`); - } - - const appSource = join(workDir, 'AppState.swift'); - writeFileSync(appSource, `import Observation - -@Observable -final class AppState { - // @Snapshotable - var counter: Int = 0 -} - -${render([{ - className: 'AppState', - fields: [{ name: 'counter', typeText: 'Int' }], - }], 'build-test', 'hash-test')}`); - const typecheck = spawnSync('swiftc', [ - '-typecheck', - '-D', 'DEBUG', - '-I', workDir, - appSource, - ], { encoding: 'utf8' }); - if (typecheck.status !== 0) { - throw new Error(`generated accessor failed Swift type checking:\n${typecheck.stderr}`); - } - }); - - test('strict JSON typing and cross-model validate-before-apply restore run correctly', () => { - if (process.platform !== 'darwin') return; - if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return; - - const coreSource = join(workDir, 'DebugBridgeCore.swift'); - const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule'); - const coreLibrary = join(workDir, 'libDebugBridgeCore.dylib'); - writeFileSync(coreSource, ` -public typealias JSONDict = [String: Any] - -@MainActor -public final class StateServer { - public typealias Restore = (JSONDict, Bool) -> RestoreResult - public enum RestoreResult { case ok, missingKey(String), typeMismatch(String) } - public static let shared = StateServer() - public var restores: [Restore] = [] - public var reads: [String: () -> Any?] = [:] - public var writes: [String: (Any) -> Bool] = [:] - private init() {} - public func register(buildId: String, accessorHash: String, atomicRestore: @escaping Restore) { - restores.append(atomicRestore) - } - public func registerAccessor( - key: String, - type: String, - read: @escaping () -> Any?, - write: @escaping (Any) -> Bool - ) { - reads[key] = read - writes[key] = write - } - public func restoreAll(_ keys: JSONDict) -> RestoreResult { - for restore in restores { - let result = restore(keys, false) - guard case .ok = result else { return result } - } - for restore in restores { - let result = restore(keys, true) - guard case .ok = result else { return result } - } - return .ok - } -} -`); - const emitCore = spawnSync('swiftc', [ - '-emit-library', '-emit-module', '-parse-as-library', - '-module-name', 'DebugBridgeCore', coreSource, - '-emit-module-path', coreModule, - '-o', coreLibrary, - ], { encoding: 'utf8' }); - if (emitCore.status !== 0) throw new Error(`failed to build runtime stub:\n${emitCore.stderr}`); - - const appSource = join(workDir, 'OptionalRoundTrip.swift'); - writeFileSync(appSource, ` -import Foundation -import Observation -import DebugBridgeCore - -@Observable -final class AppState { - // @Snapshotable - var nickname: String? = nil - // @Snapshotable - var count: Int = 1 -} - -@Observable -final class FeatureState { - // @Snapshotable - var enabled: Bool = false -} - -${render([ - { - className: 'AppState', - fields: [ - { name: 'nickname', typeText: 'String?' }, - { name: 'count', typeText: 'Int' }, - ], - }, - { - className: 'FeatureState', - fields: [{ name: 'enabled', typeText: 'Bool' }], - }, - ], 'fallback-build', 'schema-hash')} - -@main -struct Runner { - @MainActor static func main() { - let state = AppState() - let feature = FeatureState() - AppStateAccessor.register(state) - FeatureStateAccessor.register(feature) - guard StateServer.shared.reads["nickname"]?() is NSNull else { fatalError("nil read") } - guard StateServer.shared.writes["nickname"]?("Ada") == true, state.nickname == "Ada" else { - fatalError("optional write") - } - guard StateServer.shared.writes["nickname"]?(NSNull()) == true, state.nickname == nil else { - fatalError("null write") - } - guard StateServer.shared.writes["count"]?(true) == false, state.count == 1 else { - fatalError("boolean must not coerce to integer") - } - guard StateServer.shared.writes["enabled"]?(1) == false, feature.enabled == false else { - fatalError("integer must not coerce to boolean") - } - let valid = try! JSONSerialization.jsonObject( - with: Data(#"{"nickname":"Grace","count":7,"enabled":true}"#.utf8) - ) as! JSONDict - switch StateServer.shared.restoreAll(valid) { - case .ok: break - default: fatalError("valid restore") - } - guard state.nickname == "Grace", state.count == 7, feature.enabled else { fatalError("restore values") } - switch StateServer.shared.restoreAll(["nickname": NSNull(), "count": 8, "enabled": false]) { - case .ok: break - default: fatalError("null restore") - } - guard state.nickname == nil, state.count == 8, !feature.enabled else { fatalError("null restore values") } - state.nickname = "unchanged" - state.count = 9 - feature.enabled = false - switch StateServer.shared.restoreAll(["nickname": "would-partially-apply", "count": 10, "enabled": 1]) { - case .typeMismatch("enabled"): break - default: fatalError("expected mismatch") - } - guard state.nickname == "unchanged", state.count == 9, !feature.enabled else { fatalError("cross-model partial mutation") } - } -} -`); - const executable = join(workDir, 'optional-round-trip'); - const compile = spawnSync('swiftc', [ - '-D', 'DEBUG', '-I', workDir, '-L', workDir, '-lDebugBridgeCore', - '-parse-as-library', appSource, '-o', executable, - ], { encoding: 'utf8' }); - if (compile.status !== 0) throw new Error(`generated Optional accessor failed compilation:\n${compile.stderr}`); - const run = spawnSync(executable, [], { - encoding: 'utf8', - env: { ...process.env, DYLD_LIBRARY_PATH: workDir }, - }); - if (run.status !== 0) throw new Error(`generated Optional accessor failed at runtime:\n${run.stderr}`); - }); -}); - -describe('SwiftSyntax generator parity', () => { - test('isolates canonical markers and rejects inaccessible fields', () => { - if (process.platform !== 'darwin') return; - if (spawnSync('swift', ['--version'], { encoding: 'utf8' }).status !== 0) return; - - const packageDir = join(import.meta.dir, 'gen-accessors-tool'); - const inputDir = join(workDir, 'swift-syntax-input'); - const outputDir = join(workDir, 'swift-syntax-output'); - const cacheRoot = join(workDir, 'swift-syntax-cache'); - mkdirSync(inputDir); - writeFileSync(join(inputDir, 'State.swift'), ` -import Observation -@Observable -final class ToolState { - let documentation = """ - // @Snapshotable - var stringLeak: String = "no" - """ - /* // @Snapshotable */ - var blockLeak: Int = 1 - var trailingLeak: Int = 2 // @Snapshotable - // prose about @Snapshotable must not opt in - var proseLeak: Int = 3 - // @Snapshotable - var nickname: String? = nil - // @Snapshotable - var names: - [String] - = [] - // @Snapshotable - var count: Int = 0 - // @Snapshotable - var enabled: Bool = false -} -`); - const env = { - ...process.env, - GSTACK_IOS_CACHE_ROOT: cacheRoot, - APP_BUILD_ID: 'syntax-test-build', - GEN_ACCESSORS_REV: 'syntax-test-v5', - }; - const run = spawnSync('swift', [ - 'run', '--package-path', packageDir, 'gen-accessors', - '--input', inputDir, '--output', outputDir, - ], { encoding: 'utf8', env, timeout: 180_000 }); - if (run.status !== 0) throw new Error(`SwiftSyntax generator failed:\n${run.stderr}`); - const output = readFileSync(join(outputDir, 'StateAccessor.swift'), 'utf8'); - expect(output).toContain('key: "nickname"'); - expect(output).toContain('key: "names"'); - expect(output).toContain('key: "count"'); - expect(output).toContain('key: "enabled"'); - expect(output).toContain('_GStackDebugBridgeSnapshotJSON.decode(raw0'); - expect(output).toContain('atomicRestore: { keys, apply in'); - expect(output).toContain('if apply {'); - expect(output).not.toMatch(/raw\d+ as\?/); - expect(output).toContain('CFBundleShortVersionString'); - expect(output).toContain(`accessorHash: "${computeAccessorHash([{ - className: 'ToolState', - fields: [ - { name: 'nickname', typeText: 'String?' }, - { name: 'names', typeText: '[String]' }, - { name: 'count', typeText: 'Int' }, - { name: 'enabled', typeText: 'Bool' }, - ], - }])}"`); - expect(output).not.toContain('key: "stringLeak"'); - expect(output).not.toContain('key: "blockLeak"'); - expect(output).not.toContain('key: "trailingLeak"'); - expect(output).not.toContain('key: "proseLeak"'); - - const invalidInput = join(workDir, 'swift-syntax-invalid'); - const invalidOutput = join(workDir, 'swift-syntax-invalid-output'); - mkdirSync(invalidInput); - writeFileSync(join(invalidInput, 'Invalid.swift'), ` -import Observation -@Observable -final class InvalidState { - // @Snapshotable - public private(set) var token: String = "secret" - // @Snapshotable - let immutable: Int = 1 - // @Snapshotable - var inferred = 2 - // @Snapshotable - var legacy: String! = nil - // @Snapshotable - var custom: Date = .now -} - -enum Namespace { - @Observable - final class NestedState { - // @Snapshotable - var nestedValue: Int = 0 - } -} - -@Observable -final class FirstState { - // @Snapshotable - var shared: String = "first" -} - -@Observable -final class DuplicateState { - // @Snapshotable - var shared: String = "duplicate" -} -`); - const invalid = spawnSync('swift', [ - 'run', '--package-path', packageDir, 'gen-accessors', - '--input', invalidInput, '--output', invalidOutput, - ], { encoding: 'utf8', env, timeout: 180_000 }); - expect(invalid.status).toBe(4); - expect(invalid.stderr).toContain('InvalidState.token cannot be private'); - expect(invalid.stderr).toContain('InvalidState.immutable must be declared var, not let'); - expect(invalid.stderr).toContain('InvalidState.inferred requires an explicit type annotation'); - expect(invalid.stderr).toContain('InvalidState.legacy cannot use an implicitly unwrapped Optional type'); - expect(invalid.stderr).toContain("InvalidState.custom uses unsupported non-JSON snapshot type 'Date'"); - expect(invalid.stderr).toContain('nested @Observable class NestedState'); - expect(invalid.stderr).toContain("snapshot key 'shared' is declared by both FirstState and DuplicateState"); - expect(existsSync(join(invalidOutput, 'StateAccessor.swift'))).toBe(false); - }, 180_000); }); describe('collectSwiftFiles', () => { @@ -1063,17 +355,4 @@ describe('collectSwiftFiles', () => { const files = collectSwiftFiles(workDir); expect(files.sort()).toEqual([a, b].sort()); }); - - test('excludes the configured output subtree and every StateAccessor.swift', () => { - const source = join(workDir, 'App.swift'); - const staleAccessor = join(workDir, 'old', 'StateAccessor.swift'); - const outputDir = join(workDir, 'custom-output'); - mkdirSync(join(workDir, 'old')); - mkdirSync(outputDir); - writeFileSync(source, 'struct App {}'); - writeFileSync(staleAccessor, '// stale generated output'); - writeFileSync(join(outputDir, 'Poison.swift'), '// must not be scanned'); - - expect(collectSwiftFiles(workDir, { outputDir })).toEqual([source]); - }); }); diff --git a/ios-qa/scripts/gen-accessors.ts b/ios-qa/scripts/gen-accessors.ts index 738e08364..05475f918 100644 --- a/ios-qa/scripts/gen-accessors.ts +++ b/ios-qa/scripts/gen-accessors.ts @@ -5,16 +5,15 @@ // first time. Also exercised by tests so we can verify the cache + parse // behavior without a Swift toolchain. // -// The TS fast path uses a lightweight Swift lexical scanner — it understands: +// The TS port uses a stricter regex than the fork's original — it understands: // - @Observable class declarations -// - `// @Snapshotable` property markers (only marked fields are exported) -// - Legacy @Snapshotable attributes for existing integrations +// - @Snapshotable property markers (only marked fields are exported) // - Multi-line type signatures (collapses whitespace before matching) -// - JSON-native generic arrays and String-keyed dictionaries +// - Generic type parameters (matched as opaque text inside the type) // -// Invalid marked declarations (computed/immutable/inaccessible/untyped, -// nested, duplicate-keyed, or non-JSON) fail generation instead of producing -// Swift that only fails later in xcodebuild or at snapshot time. +// What it does NOT handle (deferred to the SwiftPM tool): +// - Computed properties with bodies (regex can mis-parse braces) +// - Property wrappers other than @Snapshotable // // Composite cache key (codex-flagged): swift_version || tool_git_rev || // platform_triple || source_content_hash. Source-only hash misses generator @@ -36,16 +35,6 @@ export interface AccessorSpec { fields: AccessorField[]; } -export class AccessorGenerationError extends Error { - readonly diagnostics: string[]; - - constructor(diagnostics: string[]) { - super(`gen-accessors: invalid @Snapshotable declaration(s):\n${diagnostics.map(d => ` - ${d}`).join('\n')}`); - this.name = 'AccessorGenerationError'; - this.diagnostics = diagnostics; - } -} - export interface GenInputs { inputDir: string; outputDir?: string; @@ -59,105 +48,56 @@ export interface GenInputs { export interface GenResult { outputPath: string; cacheKey: string; - accessorHash: string; specs: AccessorSpec[]; cacheHit: boolean; } const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`; -const GENERATOR_FORMAT_VERSION = 'accessor-generator-v5'; -const JSON_SCALAR_TYPES = new Set([ - 'String', - 'Bool', - 'Int', 'Int8', 'Int16', 'Int32', 'Int64', - 'UInt', 'UInt8', 'UInt16', 'UInt32', 'UInt64', - 'Float', 'Double', 'CGFloat', -]); - -export function collectSwiftFiles( - dir: string, - opts: { excludeGenerated?: boolean; outputDir?: string } = {}, -): string[] { +export function collectSwiftFiles(dir: string, opts: { excludeGenerated?: boolean } = {}): string[] { const out: string[] = []; const excludeGenerated = opts.excludeGenerated ?? true; - const root = resolve(dir); - const outputDir = opts.outputDir ? resolve(opts.outputDir) : undefined; - - function walk(currentDir: string): void { - for (const name of readdirSync(currentDir)) { - const full = join(currentDir, name); - const s = statSync(full); - if (s.isDirectory()) { - // The generated subtree must never participate in its own cache key. - // Keep the well-known-name guard for direct collectSwiftFiles callers, - // and use the actual --output path when generate() supplies one. - const isOutputSubtree = outputDir !== undefined - && outputDir !== root - && resolve(full) === outputDir; - if (excludeGenerated && (name === 'DebugBridgeGenerated' || isOutputSubtree)) continue; - walk(full); - } else if (name.endsWith('.swift')) { - // Skip generated accessor files wherever they live. Otherwise moving - // an old copy outside the output directory poisons the next cache key. - if (excludeGenerated && name === 'StateAccessor.swift') continue; - out.push(full); - } + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const s = statSync(full); + if (s.isDirectory()) { + // Skip generated output dir (when it lives under the input dir) + if (excludeGenerated && name === 'DebugBridgeGenerated') continue; + out.push(...collectSwiftFiles(full, opts)); + } else if (name.endsWith('.swift')) { + // Skip the codegen output file. Otherwise the second run picks it up, + // changes the cache key, and the cache never hits. + if (excludeGenerated && name === 'StateAccessor.swift') continue; + out.push(full); } } - - walk(root); return out.sort(); } export function parseSwift(source: string): AccessorSpec[] { const specs: AccessorSpec[] = []; - const diagnostics: string[] = []; - const masked = maskSwiftSource(source); // Find `@Observable\n(public )?(final )?class ` followed by a brace // block. We then scan inside that block for @Snapshotable fields. const classPattern = /@Observable\s*(?:(?:public|internal|fileprivate|private)\s+)?(?:final\s+)?class\s+(\w+)[^{]*\{/g; - for (const match of masked.matchAll(classPattern)) { + let match: RegExpExecArray | null; + while ((match = classPattern.exec(source)) !== null) { const className = match[1]!; - const matchOffset = match.index!; - const openBraceOffset = matchOffset + match[0].lastIndexOf('{'); - const startIdx = openBraceOffset + 1; - const endIdx = findMatchingBrace(masked, startIdx - 1); + const startIdx = classPattern.lastIndex; + const endIdx = findMatchingBrace(source, startIdx - 1); if (endIdx === -1) continue; - const body = masked.slice(startIdx, endIdx); + const body = source.slice(startIdx, endIdx); - const parsed = parseFields(body, source, startIdx, className); - if (braceDepthAt(masked, matchOffset) !== 0) { - if (parsed.fields.length > 0 || parsed.diagnostics.length > 0) { - const line = source.slice(0, matchOffset).split(/\r?\n/).length; - diagnostics.push(`${className} (line ${line}): nested @Observable types are not supported; move the type to file scope`); - } - continue; - } - diagnostics.push(...parsed.diagnostics); - const fields = parsed.fields; + const fields = parseFields(body); if (fields.length > 0) { specs.push({ className, fields }); } } - - if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics); return specs; } -function braceDepthAt(masked: string, offset: number): number { - let depth = 0; - for (let i = 0; i < offset; i++) { - if (masked[i] === '{') depth++; - else if (masked[i] === '}') depth = Math.max(0, depth - 1); - } - return depth; -} - function findMatchingBrace(s: string, openIdx: number): number { - // Strings and comments have already been blanked by maskSwiftSource, so - // braces here are syntax rather than prose or literal content. + // openIdx points at '{'. Return idx of matching '}', or -1. let depth = 0; for (let i = openIdx; i < s.length; i++) { const c = s[i]; @@ -165,305 +105,48 @@ function findMatchingBrace(s: string, openIdx: number): number { else if (c === '}') { depth--; if (depth === 0) return i; + } else if (c === '"' || c === "'") { + // skip string literal + const quote = c; + i++; + while (i < s.length && s[i] !== quote) { + if (s[i] === '\\') i++; + i++; + } + } else if (c === '/' && s[i + 1] === '/') { + // skip line comment + while (i < s.length && s[i] !== '\n') i++; + } else if (c === '/' && s[i + 1] === '*') { + i += 2; + while (i < s.length - 1 && !(s[i] === '*' && s[i + 1] === '/')) i++; + i++; } } return -1; } -/** - * Blank comments and string literals while preserving byte offsets/newlines. - * A canonical standalone `// @Snapshotable` comment is rewritten to the - * equivalent attribute token. This is intentionally lexical rather than a - * whole-file regexp: markers inside nested block comments, normal/triple/raw - * strings, trailing comments, and prose comments must never opt a field in. - */ -function maskSwiftSource(source: string): string { - const out = source.split(''); - - const blank = (start: number, end: number): void => { - for (let j = start; j < end; j++) { - if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' '; - } - }; - - let i = 0; - while (i < source.length) { - if (source[i] === '/' && source[i + 1] === '/') { - let end = i + 2; - while (end < source.length && source[end] !== '\n' && source[end] !== '\r') end++; - const lineStart = source.lastIndexOf('\n', i - 1) + 1; - const standalone = source.slice(lineStart, i).trim().length === 0; - const isMarker = standalone && source.slice(i + 2, end).trim() === '@Snapshotable'; - blank(i, end); - if (isMarker) { - const marker = '@Snapshotable'; - for (let j = 0; j < marker.length; j++) out[i + j] = marker[j]!; - } - i = end; - continue; - } - - if (source[i] === '/' && source[i + 1] === '*') { - const start = i; - i += 2; - let depth = 1; - while (i < source.length && depth > 0) { - if (source[i] === '/' && source[i + 1] === '*') { - depth++; - i += 2; - } else if (source[i] === '*' && source[i + 1] === '/') { - depth--; - i += 2; - } else { - i++; - } - } - blank(start, i); - continue; - } - - // Swift strings may be ordinary, multiline, or raw (`#"..."#` and - // `#"""..."""#`). Mask the entire literal, including interpolation; - // declarations cannot legally originate inside a literal. - let hashCount = 0; - while (source[i + hashCount] === '#') hashCount++; - const quoteIdx = i + hashCount; - if (source[quoteIdx] === '"') { - const start = i; - const triple = source.slice(quoteIdx, quoteIdx + 3) === '"""'; - const quoteCount = triple ? 3 : 1; - i = quoteIdx + quoteCount; - while (i < source.length) { - if (hashCount === 0 && source[i] === '\\') { - i += 2; - continue; - } - const quoteRun = source.slice(i, i + quoteCount) === '"'.repeat(quoteCount); - const hashesMatch = source.slice(i + quoteCount, i + quoteCount + hashCount) === '#'.repeat(hashCount); - if (quoteRun && hashesMatch) { - i += quoteCount + hashCount; - break; - } - i++; - } - blank(start, i); - continue; - } - - i++; - } - return out.join(''); -} - -const DECL_MODIFIERS = new Set([ - 'public', 'internal', 'package', 'open', 'private', 'fileprivate', - 'final', 'static', 'class', 'lazy', 'weak', 'unowned', 'override', - 'nonisolated', 'isolated', 'borrowing', 'consuming', -]); - -function parseFields( - body: string, - fullSource: string, - bodyOffset: number, - className: string, -): { fields: AccessorField[]; diagnostics: string[] } { +function parseFields(body: string): AccessorField[] { + // Look for @Snapshotable followed by var/let declarations. Allow attribute + // ordering: `@Snapshotable var name: Type` OR `@Snapshotable\n var name: Type`. + // Multi-line types are handled by greedy non-newline match in the type, but + // we collapse adjacent whitespace first to avoid false negatives. + const normalized = body.replace(/[\t ]*\r?\n[\t ]*/g, ' '); + const fieldPattern = /@Snapshotable\s+(?:(?:public|internal|fileprivate|private)\s+)?(?:var|let)\s+(\w+)\s*:\s*([^={]+?)(?=\s*(?:=|\{|@Snapshotable|\bvar\b|\blet\b|\bfunc\b|\}|$))/g; const fields: AccessorField[] = []; - const diagnostics: string[] = []; - let braceDepth = 0; - - const lineFor = (localOffset: number): number => { - const absolute = bodyOffset + localOffset; - let line = 1; - for (let j = 0; j < absolute; j++) if (fullSource[j] === '\n') line++; - return line; - }; - const fail = (offset: number, message: string): void => { - diagnostics.push(`${className} (line ${lineFor(offset)}): ${message}`); - }; - const skipWhitespace = (start: number): number => { - let at = start; - while (at < body.length && /\s/.test(body[at]!)) at++; - return at; - }; - const identifierAt = (start: number): { text: string; end: number } | undefined => { - const m = /^[A-Za-z_]\w*/.exec(body.slice(start)); - return m ? { text: m[0], end: start + m[0].length } : undefined; - }; - const skipBalancedParens = (start: number): number => { - if (body[start] !== '(') return start; - let depth = 0; - for (let at = start; at < body.length; at++) { - if (body[at] === '(') depth++; - else if (body[at] === ')' && --depth === 0) return at + 1; - } - return body.length; - }; - - for (let i = 0; i < body.length; i++) { - if (body[i] === '{') { - braceDepth++; - continue; - } - if (body[i] === '}') { - braceDepth--; - continue; - } - if (braceDepth !== 0 || !body.startsWith('@Snapshotable', i)) continue; - const before = i === 0 ? '' : body[i - 1]!; - const after = body[i + '@Snapshotable'.length] ?? ''; - if (/\w/.test(before) || /\w/.test(after)) continue; - - const markerOffset = i; - let at = skipWhitespace(i + '@Snapshotable'.length); - const modifiers: string[] = []; - let bindingKind: 'var' | 'let' | undefined; - - // Permit other Swift attributes between the marker and declaration. They - // remain the compiler's responsibility; this scanner only owns the - // snapshot contract. Parenthesized attribute arguments are skipped. - while (body[at] === '@') { - const attribute = identifierAt(at + 1); - if (!attribute) break; - at = skipWhitespace(attribute.end); - if (body[at] === '(') at = skipBalancedParens(at); - at = skipWhitespace(at); - } - - while (at < body.length) { - const token = identifierAt(at); - if (!token) break; - if (token.text === 'var' || token.text === 'let') { - bindingKind = token.text; - at = token.end; - break; - } - if (!DECL_MODIFIERS.has(token.text)) break; - let modifier = token.text; - at = skipWhitespace(token.end); - if (body[at] === '(') { - const end = skipBalancedParens(at); - modifier += body.slice(at, end).replace(/\s+/g, ''); - at = skipWhitespace(end); - } - modifiers.push(modifier); - } - - if (!bindingKind) { - fail(markerOffset, '@Snapshotable must immediately precede a stored property declaration'); - continue; - } - - at = skipWhitespace(at); - const identifier = identifierAt(at); - const fieldName = identifier?.text ?? ''; - if (!identifier) { - fail(markerOffset, '@Snapshotable only supports a single identifier binding'); - continue; - } - at = skipWhitespace(identifier.end); - - if (bindingKind === 'let') { - fail(markerOffset, `@Snapshotable field '${fieldName}' must be declared var, not let`); - continue; - } - if (modifiers.some(m => /^(?:private|fileprivate)(?:\(set\))?$/.test(m))) { - fail(markerOffset, `@Snapshotable field '${fieldName}' cannot be private, fileprivate, private(set), or fileprivate(set)`); - continue; - } - if (modifiers.some(m => m === 'static' || m === 'class')) { - fail(markerOffset, `@Snapshotable field '${fieldName}' must be an instance property`); - continue; - } - if (body[at] !== ':') { - fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`); - continue; - } - - at = skipWhitespace(at + 1); - const typeStart = at; - let parens = 0; - let brackets = 0; - let angles = 0; - let typeEnd = at; - let delimiter = ''; - for (; at < body.length; at++) { - const c = body[at]!; - if (c === '(') parens++; - else if (c === ')') parens--; - else if (c === '[') brackets++; - else if (c === ']') brackets--; - else if (c === '<') angles++; - else if (c === '>') angles = Math.max(0, angles - 1); - const topLevel = parens === 0 && brackets === 0 && angles === 0; - if (topLevel && (c === '=' || c === '{' || c === ',' || c === ';')) { - delimiter = c; - break; - } - if (topLevel && (c === '\n' || c === '\r')) { - const soFar = body.slice(typeStart, at).trim(); - if (soFar.length > 0 && !/(?:->|[&.,])\s*$/.test(soFar)) break; - } - typeEnd = at + 1; - } - const typeText = body.slice(typeStart, typeEnd).replace(/\s+/g, ' ').trim(); - if (typeText.length === 0) { - fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`); - continue; - } - if (delimiter === '{') { - fail(markerOffset, `@Snapshotable field '${fieldName}' must be stored and writable`); - continue; - } - if (delimiter === ',') { - fail(markerOffset, '@Snapshotable declarations must contain exactly one binding'); - continue; - } - if (delimiter === '=') { - // A declaration such as `var a: Int = 1, b: Int = 2` reaches `=` - // before its binding comma. Scan the initializer at syntax depth zero - // so commas in arrays/calls/closures remain valid. - let initializerAt = at + 1; - let expressionParens = 0; - let expressionBrackets = 0; - let expressionBraces = 0; - let hasSecondBinding = false; - for (; initializerAt < body.length; initializerAt++) { - const c = body[initializerAt]!; - if (c === '(') expressionParens++; - else if (c === ')') expressionParens--; - else if (c === '[') expressionBrackets++; - else if (c === ']') expressionBrackets--; - else if (c === '{') expressionBraces++; - else if (c === '}') { - if (expressionBraces === 0) break; - expressionBraces--; - } - const topLevel = expressionParens === 0 && expressionBrackets === 0 && expressionBraces === 0; - if (topLevel && c === ',') { - hasSecondBinding = true; - break; - } - if (topLevel && (c === '\n' || c === '\r' || c === ';')) break; - } - if (hasSecondBinding) { - fail(markerOffset, '@Snapshotable declarations must contain exactly one binding'); - continue; - } - } - const wrappedOptional = optionalWrappedType(typeText); - if (wrappedOptional !== undefined && optionalWrappedType(wrappedOptional) !== undefined) { - fail(markerOffset, `@Snapshotable field '${fieldName}' cannot use a nested Optional type`); - continue; - } - const typeIssue = snapshotTypeIssue(typeText); - if (typeIssue !== undefined) { - fail(markerOffset, `@Snapshotable field '${fieldName}' ${typeIssue}`); - continue; - } - - fields.push({ name: fieldName, typeText }); + let m: RegExpExecArray | null; + while ((m = fieldPattern.exec(normalized)) !== null) { + // Codex catch: skip fields that have a computed body (`{ get ... }` or + // `{ didSet ... }` after the type). The match boundary stops before `{`, + // so we peek at what comes after the type in the original body. + const afterMatchIdx = m.index + m[0].length; + const afterMatch = normalized.slice(afterMatchIdx, afterMatchIdx + 4).trim(); + // If the next non-space character is `{`, this is a computed property. + // We're conservative: snapshot-eligible fields must be stored properties + // (initialized with `=` or just declared). + if (afterMatch.startsWith('{')) continue; + fields.push({ name: m[1]!, typeText: m[2]!.trim() }); } - return { fields, diagnostics }; + return fields; } export function computeCacheKey(inputs: { @@ -471,263 +154,35 @@ export function computeCacheKey(inputs: { swiftVersion: string; toolGitRev: string; platformTriple: string; - buildId?: string; }): string { const h = createHash('sha256'); - h.update(`${GENERATOR_FORMAT_VERSION}|swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|build=${inputs.buildId ?? 'unknown'}|`); + h.update(`swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|`); for (const f of inputs.swiftFiles) { const content = readFileSync(f); - // Cache identity is content-based. Absolute checkout paths must not make - // equivalent source trees produce different generated output. - h.update(`${content.length}:`); + h.update(`${f}:${content.length}:`); h.update(content); h.update('|'); } return h.digest('hex'); } -/** Stable snapshot-schema fingerprint, deliberately independent of cache ABI, - * source paths, app build provenance, and unmarked source. Field/class order is - * source order because restore payload compatibility is an ordered contract. */ -export function computeAccessorHash(specs: AccessorSpec[]): string { - let signature = 'snapshot-schema-v1\n'; - for (const spec of specs) { - signature += `C${Buffer.byteLength(spec.className)}:${spec.className}\n`; - for (const field of spec.fields) { - signature += `F${Buffer.byteLength(field.name)}:${field.name}`; - signature += `T${Buffer.byteLength(field.typeText)}:${field.typeText}\n`; - } - signature += 'E\n'; - } - return createHash('sha256').update(signature).digest('hex'); -} - -/** Return the wrapped type for one top-level Optional spelling. */ -export function optionalWrappedType(typeText: string): string | undefined { - const type = typeText.trim(); - if (type.endsWith('?')) { - const wrapped = type.slice(0, -1).trim(); - return wrapped.length > 0 ? wrapped : undefined; - } - - const optional = /^(?:Swift\.)?Optional\s*') { - depth--; - if (depth === 0) { - if (type.slice(i + 1).trim().length > 0) return undefined; - const wrapped = type.slice(open + 1, i).trim(); - return wrapped.length > 0 ? wrapped : undefined; - } - } - } - return undefined; -} - -function splitTopLevel(type: string, delimiter: string): string[] { - const parts: string[] = []; - let start = 0; - let angle = 0; - let square = 0; - let paren = 0; - for (let i = 0; i < type.length; i++) { - const char = type[i]!; - if (char === '<') angle++; - else if (char === '>') angle--; - else if (char === '[') square++; - else if (char === ']') square--; - else if (char === '(') paren++; - else if (char === ')') paren--; - else if (char === delimiter && angle === 0 && square === 0 && paren === 0) { - parts.push(type.slice(start, i)); - start = i + 1; - } - } - parts.push(type.slice(start)); - return parts; -} - -function genericArgument(type: string, name: string): string | undefined { - const prefix = `${name}<`; - if (!type.startsWith(prefix) || !type.endsWith('>')) return undefined; - let depth = 0; - for (let i = name.length; i < type.length; i++) { - if (type[i] === '<') depth++; - else if (type[i] === '>') { - depth--; - if (depth === 0 && i !== type.length - 1) return undefined; - if (depth < 0) return undefined; - } - } - return depth === 0 ? type.slice(prefix.length, -1) : undefined; -} - -/** Return a user-facing suffix when a Swift type cannot round-trip through - * Foundation JSON without custom encoding. The accepted grammar is deliberately - * narrow: JSON scalar values, arrays, string-keyed dictionaries, and Optional - * compositions of those types. */ -export function snapshotTypeIssue(typeText: string): string | undefined { - const type = typeText.replace(/\s+/g, ''); - if (type.length === 0) return 'requires a JSON-compatible type'; - if (type.endsWith('!')) { - return 'cannot use an implicitly unwrapped Optional; use T? instead'; - } - - if (type.endsWith('?')) { - const wrapped = type.slice(0, -1); - if (wrapped.endsWith('?') - || genericArgument(wrapped, 'Optional') !== undefined - || genericArgument(wrapped, 'Swift.Optional') !== undefined) { - return 'cannot use a nested Optional type'; - } - return snapshotTypeIssue(wrapped); - } - const optional = genericArgument(type, 'Optional') ?? genericArgument(type, 'Swift.Optional'); - if (optional !== undefined) { - if (optional.endsWith('?') - || genericArgument(optional, 'Optional') !== undefined - || genericArgument(optional, 'Swift.Optional') !== undefined) { - return 'cannot use a nested Optional type'; - } - return snapshotTypeIssue(optional); - } - - const scalar = type === 'CoreGraphics.CGFloat' - ? 'CGFloat' - : (type.startsWith('Swift.') ? type.slice('Swift.'.length) : type); - if (JSON_SCALAR_TYPES.has(scalar)) return undefined; - - if (type.startsWith('[') && type.endsWith(']')) { - const inner = type.slice(1, -1); - const dictionaryParts = splitTopLevel(inner, ':'); - if (dictionaryParts.length === 1) return snapshotTypeIssue(inner); - if (dictionaryParts.length === 2) { - const key = dictionaryParts[0]!.replace(/^Swift\./, ''); - if (key !== 'String') return 'must use String keys for snapshot dictionaries'; - return snapshotTypeIssue(dictionaryParts[1]!); - } - return 'requires a JSON-compatible array or dictionary type'; - } - - const array = genericArgument(type, 'Array') ?? genericArgument(type, 'Swift.Array'); - if (array !== undefined) return snapshotTypeIssue(array); - const dictionary = genericArgument(type, 'Dictionary') ?? genericArgument(type, 'Swift.Dictionary'); - if (dictionary !== undefined) { - const parts = splitTopLevel(dictionary, ','); - if (parts.length !== 2) return 'requires a JSON-compatible dictionary type'; - const key = parts[0]!.replace(/^Swift\./, ''); - if (key !== 'String') return 'must use String keys for snapshot dictionaries'; - return snapshotTypeIssue(parts[1]!); - } - - return `uses unsupported snapshot type '${typeText}'; use JSON scalar, array, or String-keyed dictionary types`; -} - -export function validateAccessorSpecs(specs: AccessorSpec[]): void { - const owners = new Map(); - const diagnostics: string[] = []; - for (const spec of specs) { - for (const field of spec.fields) { - const previous = owners.get(field.name); - if (previous !== undefined) { - diagnostics.push(`snapshot key '${field.name}' is declared by both ${previous} and ${spec.className}; keys must be unique across @Observable types`); - } else { - owners.set(field.name, spec.className); - } - } - } - if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics); -} - -function swiftStringLiteral(value: string): string { - return JSON.stringify(value); -} - export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string { - validateAccessorSpecs(specs); let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n'; - out += '#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n'; + out += '#if DEBUG\nimport Foundation\nimport DebugBridge\n\n'; for (const spec of specs) { - // Accessors compile in the app target beside its usually-internal state - // types. Making this API public would make valid internal models fail - // Swift type checking (a public signature cannot expose an internal type). - out += `@MainActor\nenum ${spec.className}Accessor {\n`; - out += ` private static func decodeSnapshotValue(_ value: Any, as type: T.Type) -> T? {\n`; - out += ` guard JSONSerialization.isValidJSONObject(["value": value]),\n`; - out += ` let data = try? JSONSerialization.data(withJSONObject: ["value": value]),\n`; - out += ` let decoded = try? JSONDecoder().decode([String: T].self, from: data) else { return nil }\n`; - out += ` return decoded["value"]\n`; - out += ` }\n\n`; - out += ` static func register(_ state: ${spec.className}) {\n`; + out += `@MainActor\npublic enum ${spec.className}Accessor {\n`; + out += ` public static func register(_ state: ${spec.className}) {\n`; out += ` StateServer.shared.register(\n`; - out += ` buildId: {\n`; - out += ` let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String\n`; - out += ` let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String\n`; - out += ` if let shortVersion, let bundleVersion { return "\\(shortVersion) (\\(bundleVersion))" }\n`; - out += ` return shortVersion ?? bundleVersion ?? ${swiftStringLiteral(buildId)}\n`; - out += ` }(),\n`; - out += ` accessorHash: ${swiftStringLiteral(accessorHash)},\n`; - out += ` atomicRestore: { keys, apply in\n`; - out += ` // Validate every key and value before assignment.\n`; - out += ` // Successful assignments are sequential on MainActor.\n`; - spec.fields.forEach((field, index) => { - out += ` guard let raw${index} = keys["${field.name}"] else {\n`; - out += ` return .missingKey("${field.name}")\n`; - out += ` }\n`; - const wrapped = optionalWrappedType(field.typeText); - if (wrapped !== undefined) { - out += ` let restored${index}: ${field.typeText}\n`; - out += ` if raw${index} is NSNull {\n`; - out += ` restored${index} = nil\n`; - out += ` } else if let typed = Self.decodeSnapshotValue(raw${index}, as: ${wrapped}.self) {\n`; - out += ` restored${index} = typed\n`; - out += ` } else {\n`; - out += ` return .typeMismatch("${field.name}")\n`; - out += ` }\n`; - } else { - out += ` guard let restored${index} = Self.decodeSnapshotValue(raw${index}, as: ${field.typeText}.self) else {\n`; - out += ` return .typeMismatch("${field.name}")\n`; - out += ` }\n`; - } - }); - out += ` if apply {\n`; - spec.fields.forEach((field, index) => { - out += ` state.${field.name} = restored${index}\n`; - }); - out += ` }\n`; - out += ` return .ok\n`; - out += ` }\n`; + out += ` buildId: "${buildId}",\n`; + out += ` accessorHash: "${accessorHash}",\n`; + out += ` atomicRestore: { _ in .ok }\n`; out += ` )\n`; for (const field of spec.fields) { - const wrapped = optionalWrappedType(field.typeText); out += ` StateServer.shared.registerAccessor(\n`; out += ` key: "${field.name}",\n`; out += ` type: "${field.typeText}",\n`; - if (wrapped !== undefined) { - out += ` read: {\n`; - out += ` guard let value = state.${field.name} else { return NSNull() }\n`; - out += ` return value as Any\n`; - out += ` },\n`; - } else { - out += ` read: { state.${field.name} as Any? },\n`; - } - out += ` write: { value in\n`; - if (wrapped !== undefined) { - out += ` if value is NSNull {\n`; - out += ` state.${field.name} = nil\n`; - out += ` return true\n`; - out += ` }\n`; - out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${wrapped}.self) else { return false }\n`; - } else { - out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${field.typeText}.self) else { return false }\n`; - } - out += ` state.${field.name} = typed\n`; - out += ` return true\n`; - out += ` }\n`; + out += ` read: { state.${field.name} as Any? },\n`; + out += ` write: { _ in false }\n`; out += ` )\n`; } out += ` }\n}\n\n`; @@ -760,15 +215,6 @@ function detectToolGitRev(): string { } } -function detectBuildId(): string { - if (process.env.APP_BUILD_ID) return process.env.APP_BUILD_ID; - const marketing = process.env.MARKETING_VERSION; - const build = process.env.CURRENT_PROJECT_VERSION; - if (marketing && build) return `${marketing} (${build})`; - if (build) return build; - return 'unknown'; -} - export function defaultCacheRoot(): string { return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors'); } @@ -777,25 +223,13 @@ export function generate(inputs: GenInputs): GenResult { const inputDir = resolve(inputs.inputDir); const outputDir = resolve(inputs.outputDir ?? inputDir); const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot(); - const swiftFiles = collectSwiftFiles(inputDir, { outputDir }); - const buildId = inputs.buildId ?? detectBuildId(); - - // Parse before cache lookup. This keeps diagnostics deterministic even when - // an older cache entry exists, and gives us the schema-only accessor hash. - const allSpecs: AccessorSpec[] = []; - for (const f of swiftFiles) { - const src = readFileSync(f, 'utf-8'); - allSpecs.push(...parseSwift(src)); - } - validateAccessorSpecs(allSpecs); - const accessorHash = computeAccessorHash(allSpecs); + const swiftFiles = collectSwiftFiles(inputDir); const cacheKey = computeCacheKey({ swiftFiles, swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(), toolGitRev: inputs.toolGitRev ?? detectToolGitRev(), platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM, - buildId, }); const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift'); @@ -808,13 +242,18 @@ export function generate(inputs: GenInputs): GenResult { return { outputPath: finalOutput, cacheKey, - accessorHash, - specs: allSpecs, + specs: [], // intentionally empty on cache hit (no need to re-parse) cacheHit: true, }; } - const rendered = render(allSpecs, buildId, accessorHash); + // Parse + render fresh + const allSpecs: AccessorSpec[] = []; + for (const f of swiftFiles) { + const src = readFileSync(f, 'utf-8'); + allSpecs.push(...parseSwift(src)); + } + const rendered = render(allSpecs, inputs.buildId ?? 'unknown', cacheKey); writeFileSync(finalOutput, rendered); // Populate cache (best-effort — cache failures don't break codegen). @@ -828,7 +267,6 @@ export function generate(inputs: GenInputs): GenResult { return { outputPath: finalOutput, cacheKey, - accessorHash, specs: allSpecs, cacheHit: false, }; @@ -862,18 +300,10 @@ if (import.meta.main) { const inputDir = args[inputIdx + 1]!; const outputIdx = args.indexOf('--output'); const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined; - try { - const result = generate({ inputDir, outputDir }); - process.stdout.write( - result.cacheHit - ? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n` - : `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`, - ); - } catch (error) { - if (error instanceof AccessorGenerationError) { - process.stderr.write(`${error.message}\n`); - process.exit(4); - } - throw error; - } + const result = generate({ inputDir, outputDir }); + process.stdout.write( + result.cacheHit + ? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n` + : `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`, + ); } diff --git a/ios-qa/templates/Bridges.swift.template b/ios-qa/templates/Bridges.swift.template index 4a91142de..bf7af6e3f 100644 --- a/ios-qa/templates/Bridges.swift.template +++ b/ios-qa/templates/Bridges.swift.template @@ -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/ios-qa/templates/DebugBridgeManager.swift.template b/ios-qa/templates/DebugBridgeManager.swift.template index 8dfb96d88..e18e2fb05 100644 --- a/ios-qa/templates/DebugBridgeManager.swift.template +++ b/ios-qa/templates/DebugBridgeManager.swift.template @@ -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/ios-qa/templates/DebugBridgeTouch.h.template b/ios-qa/templates/DebugBridgeTouch.h.template index 7106791bf..e44034530 100644 --- a/ios-qa/templates/DebugBridgeTouch.h.template +++ b/ios-qa/templates/DebugBridgeTouch.h.template @@ -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/ios-qa/templates/DebugBridgeTouch.m.template b/ios-qa/templates/DebugBridgeTouch.m.template index 2c664f08a..a8b413d85 100644 --- a/ios-qa/templates/DebugBridgeTouch.m.template +++ b/ios-qa/templates/DebugBridgeTouch.m.template @@ -131,36 +131,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; @@ -207,7 +177,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 @@ -265,10 +234,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; @@ -287,17 +252,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/ios-qa/templates/DebugBridgeWiring.swift.template b/ios-qa/templates/DebugBridgeWiring.swift.template index bf7016d87..009a70861 100644 --- a/ios-qa/templates/DebugBridgeWiring.swift.template +++ b/ios-qa/templates/DebugBridgeWiring.swift.template @@ -6,35 +6,22 @@ // the DebugBridge target. #if DEBUG -import Foundation -import DebugBridgeCore -#if canImport(UIKit) -import DebugBridgeUI -#endif +import DebugBridge @MainActor -func startGstackDebugBridge( - appState: State, - register: (State) -> Void -) { +func startGstackDebugBridge(appState: AppState) { // Read --recording flag from launch arguments let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording") - // Install the UI resolvers before opening the listener. A warm daemon can - // issue its first request as soon as StateServer starts; it must never see - // the default empty screenshot/elements/mutation handlers. - #if canImport(UIKit) - DebugBridgeUIWiring.installAll() - #endif + // Install accessibility + screenshot + mutation bridges before starting + // the server so the first authenticated request can use them. + ElementsBridge.resolver = { AccessibilityScanner.snapshot() } + ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() } + MutationBridge.resolver = { op, payload in + MutationDispatcher.shared.run(op: op, payload: payload) + } - // Generated typed accessors live in the app target, so pass their register - // function into the package instead of asking DebugBridgeCore to import - // app-owned types. - DebugBridgeManager.shared.start(appState: appState, register: register) - - #if canImport(UIKit) - DebugOverlayWindow.shared.install(recording: recording) - #endif + DebugBridgeManager.shared.start(appState: appState, recording: recording) } #endif @@ -46,10 +33,7 @@ func startGstackDebugBridge( // // init() { // #if DEBUG -// startGstackDebugBridge( -// appState: appState, -// register: MyAppStateAccessor.register -// ) +// startGstackDebugBridge(appState: appState) // #endif // } // diff --git a/ios-qa/templates/StateAccessor.swift.template b/ios-qa/templates/StateAccessor.swift.template index a9a87b6f8..07de99e19 100644 --- a/ios-qa/templates/StateAccessor.swift.template +++ b/ios-qa/templates/StateAccessor.swift.template @@ -3,12 +3,12 @@ // // This file is a TEMPLATE that gen-accessors-tool fills in. The placeholders // are filled per-class from swift-syntax AST inspection of the app's -// @Observable types. Only properties preceded by `// @Snapshotable` are emitted. +// @Observable types. Only properties marked with @Snapshotable are emitted. // // {{CLASS_NAME}} — the canonical AppState struct name // {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time // {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint) -// {{ACCESSORS}} — generated register/read/write blocks per marked field +// {{ACCESSORS}} — generated register/read/write blocks per @Snapshotable field #if DEBUG @@ -21,16 +21,13 @@ public enum {{CLASS_NAME}}Accessor { StateServer.shared.register( buildId: "{{APP_BUILD_ID}}", accessorHash: "{{ACCESSOR_HASH}}", - atomicRestore: { keys, apply in - // Validate every key + type before mutating any state. Invalid - // input therefore cannot leave a partially restored model. + atomicRestore: { keys in + // Validate every key + type FIRST, then apply in one struct + // assignment so SwiftUI observers see exactly one change. var snapshot = state.snapshotable {{VALIDATION_BLOCK}} - // StateServer validates every model first, then invokes the - // same handlers with apply=true for a cross-model commit. - if apply { - state.snapshotable = snapshot - } + // Apply atomically. + state.snapshotable = snapshot return .ok } ) diff --git a/ios-qa/templates/StateServer.swift.template b/ios-qa/templates/StateServer.swift.template index b8441df16..803bedf31 100644 --- a/ios-qa/templates/StateServer.swift.template +++ b/ios-qa/templates/StateServer.swift.template @@ -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/ios-sync/SKILL.md b/ios-sync/SKILL.md index 95e0bca0a..2f689c4d6 100644 --- a/ios-sync/SKILL.md +++ b/ios-sync/SKILL.md @@ -806,53 +806,49 @@ After `/ios-qa` is installed in an app, the user may: 1. Add new `@Observable` classes or properties that need accessor coverage. 2. Upgrade gstack to a newer version with hardening fixes. -3. Move the `// @Snapshotable` generator marker comment to a different field. +3. Move the `@Snapshotable` marker to a different field. This skill regenerates the relevant artifacts in place. -**Templates live in upstream gstack.** The installed -`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only -the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch -and wildcard-copy patterns are gone. +**Templates live in upstream gstack.** This skill resolves them from +`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's +`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch +pattern is gone. ## Phase 1: Detect installed version 1. Read `/DebugBridgeGenerated/.gstack-version` (written by /ios-qa during install). If missing, treat the install as "unknown old version". -2. Read upstream version from `$GSTACK_ROOT/VERSION`. +2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the + value baked into the installed gstack binary). 3. If versions match AND no new `@Observable` classes were added, exit early with "already up to date". ## Phase 2: Regenerate codegen output -Run the deterministic regenerator once. `--app-source` is the directory the -accessor scanner should inspect; `--bridge-dir` is the local Swift package -that the app links in Debug builds: +Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly): ```bash -~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ - --app-source "$APP_SOURCE_DIR" \ - --bridge-dir "$APP_SOURCE_DIR/DebugBridge" +swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \ + gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated" ``` -The command removes only the known obsolete generated files from the former -flat `DebugBridgeGenerated/` layout before emitting the current accessor. -Generation accepts file-scope observable classes and JSON-native scalar, -array, String-keyed dictionary, and Optional field types. It rejects custom -types, implicitly unwrapped Optionals, nested observable classes, and duplicate -snapshot keys before writing a completion marker. - The composite-hash cache key handles whether anything actually needs regenerating; if Swift version, generator git rev, lockfile, source content, and platform triple all match the cache, this is a ~50ms no-op. -## Phase 3: Review the generated diff +## Phase 3: Update templated Swift files in place -1. Review changes under `/DebugBridge/` and - `/DebugBridgeGenerated/StateAccessor.swift`. -2. Confirm the command did not modify the app's handwritten Swift files. -3. Keep app-specific wiring in the app target; canonical bridge package files - are regenerated from upstream and should not be hand-edited. +For each file that comes from `ios-qa/templates/*.swift.template`: + +1. Read the current installed file at + `/DebugBridgeGenerated/.swift`. +2. Read the upstream template at + `$GSTACK_HOME/ios-qa/templates/.swift.template`. +3. If the installed file has a `// GSTACK-EDIT-LINE` marker, fold the user's + edits forward. +4. Otherwise, replace the file outright with the new template (after + AskUserQuestion if the diff is non-trivial). ## Phase 4: Verify @@ -866,6 +862,5 @@ and platform triple all match the cache, this is a ~50ms no-op. | Symptom | Action | |---|---| | Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error | -| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. | -| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. | -| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. | +| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. | +| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` | diff --git a/ios-sync/SKILL.md.tmpl b/ios-sync/SKILL.md.tmpl index add54c3d8..156a33c4c 100644 --- a/ios-sync/SKILL.md.tmpl +++ b/ios-sync/SKILL.md.tmpl @@ -35,53 +35,49 @@ After `/ios-qa` is installed in an app, the user may: 1. Add new `@Observable` classes or properties that need accessor coverage. 2. Upgrade gstack to a newer version with hardening fixes. -3. Move the `// @Snapshotable` generator marker comment to a different field. +3. Move the `@Snapshotable` marker to a different field. This skill regenerates the relevant artifacts in place. -**Templates live in upstream gstack.** The installed -`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only -the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch -and wildcard-copy patterns are gone. +**Templates live in upstream gstack.** This skill resolves them from +`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's +`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch +pattern is gone. ## Phase 1: Detect installed version 1. Read `/DebugBridgeGenerated/.gstack-version` (written by /ios-qa during install). If missing, treat the install as "unknown old version". -2. Read upstream version from `$GSTACK_ROOT/VERSION`. +2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the + value baked into the installed gstack binary). 3. If versions match AND no new `@Observable` classes were added, exit early with "already up to date". ## Phase 2: Regenerate codegen output -Run the deterministic regenerator once. `--app-source` is the directory the -accessor scanner should inspect; `--bridge-dir` is the local Swift package -that the app links in Debug builds: +Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly): ```bash -~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ - --app-source "$APP_SOURCE_DIR" \ - --bridge-dir "$APP_SOURCE_DIR/DebugBridge" +swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \ + gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated" ``` -The command removes only the known obsolete generated files from the former -flat `DebugBridgeGenerated/` layout before emitting the current accessor. -Generation accepts file-scope observable classes and JSON-native scalar, -array, String-keyed dictionary, and Optional field types. It rejects custom -types, implicitly unwrapped Optionals, nested observable classes, and duplicate -snapshot keys before writing a completion marker. - The composite-hash cache key handles whether anything actually needs regenerating; if Swift version, generator git rev, lockfile, source content, and platform triple all match the cache, this is a ~50ms no-op. -## Phase 3: Review the generated diff +## Phase 3: Update templated Swift files in place -1. Review changes under `/DebugBridge/` and - `/DebugBridgeGenerated/StateAccessor.swift`. -2. Confirm the command did not modify the app's handwritten Swift files. -3. Keep app-specific wiring in the app target; canonical bridge package files - are regenerated from upstream and should not be hand-edited. +For each file that comes from `ios-qa/templates/*.swift.template`: + +1. Read the current installed file at + `/DebugBridgeGenerated/.swift`. +2. Read the upstream template at + `$GSTACK_HOME/ios-qa/templates/.swift.template`. +3. If the installed file has a `// GSTACK-EDIT-LINE` marker, fold the user's + edits forward. +4. Otherwise, replace the file outright with the new template (after + AskUserQuestion if the diff is non-trivial). ## Phase 4: Verify @@ -95,6 +91,5 @@ and platform triple all match the cache, this is a ~50ms no-op. | Symptom | Action | |---|---| | Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error | -| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. | -| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. | -| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. | +| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. | +| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` | diff --git a/test/skill-e2e-ios-swift-build.test.ts b/test/skill-e2e-ios-swift-build.test.ts index d6e4bf0e1..4347e2c05 100644 --- a/test/skill-e2e-ios-swift-build.test.ts +++ b/test/skill-e2e-ios-swift-build.test.ts @@ -26,8 +26,31 @@ 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'); +// Parity: canonical Obj-C touch templates must match the fixture's working +// copy. The fixture is the only place the .m / .h are exercised end-to-end +// on a real device, so any divergence means consuming apps would ship a +// stale, untested version of the SwiftUI hit-test fix. describe('template ↔ fixture parity', () => { + test('DebugBridgeTouch.h.template matches fixture include', () => { + const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'); + const fixture = readFileSync( + join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), + 'utf-8', + ); + expect(tmpl).toBe(fixture); + }); + + test('DebugBridgeTouch.m.template matches fixture .m', () => { + const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); + const fixture = readFileSync( + join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), + 'utf-8', + ); + expect(tmpl).toBe(fixture); + }); + test('Package.swift.template declares all 3 DebugBridge targets', () => { const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); // Each target must be present as a library product AND a target definition. @@ -39,6 +62,19 @@ describe('template ↔ fixture parity', () => { 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 = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); + expect(tmpl.split(/\r?\n/, 1)[0]).toBe('// swift-tools-version:5.9'); + }); + test('KIF-derived touch source preserves Apache-2.0 attribution', () => { const implementation = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); expect(implementation).toContain('Copyright 2011-2016 Square, Inc. and KIF contributors.'); @@ -146,18 +182,9 @@ describeIfSwift('swift build invariants', () => { const fixtureDir = join(workDir, 'FixtureApp'); try { cpSync(FIXTURE_PATH, fixtureDir, { recursive: true }); - writeFileSync( - join(fixtureDir, 'Package.swift'), - readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'), - ); - writeFileSync( - join(fixtureDir, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), - readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'), - ); - writeFileSync( - join(fixtureDir, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), - readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'), - ); + writeFileSync(join(fixtureDir, 'Package.swift'), readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'))); + writeFileSync(join(fixtureDir, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'))); + writeFileSync(join(fixtureDir, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'))); expect(spawnSync('xcodegen', [ 'generate', '--spec', join(fixtureDir, 'project.yml'), '--project', fixtureDir, '--project-root', fixtureDir, 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