import ClawdbotKit import Foundation import Network import Testing import UIKit @testable import Clawdbot private struct KeychainEntry: Hashable { let service: String let account: String } private let bridgeService = "com.clawdbot.bridge" private let nodeService = "com.clawdbot.node" private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID") private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID") private actor MockBridgePairingClient: BridgePairingClient { private(set) var lastToken: String? private let resultToken: String init(resultToken: String) { self.resultToken = resultToken } func pairAndHello( endpoint: NWEndpoint, hello: BridgeHello, tls: BridgeTLSParams?, onStatus: (@Sendable (String) -> Void)?) async throws -> String { self.lastToken = hello.token onStatus?("Testing…") return self.resultToken } } private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T { let defaults = UserDefaults.standard var snapshot: [String: Any?] = [:] for key in updates.keys { snapshot[key] = defaults.object(forKey: key) } for (key, value) in updates { if let value { defaults.set(value, forKey: key) } else { defaults.removeObject(forKey: key) } } defer { for (key, value) in snapshot { if let value { defaults.set(value, forKey: key) } else { defaults.removeObject(forKey: key) } } } return try body() } @MainActor private func withUserDefaults( _ updates: [String: Any?], _ body: () async throws -> T) async rethrows -> T { let defaults = UserDefaults.standard var snapshot: [String: Any?] = [:] for key in updates.keys { snapshot[key] = defaults.object(forKey: key) } for (key, value) in updates { if let value { defaults.set(value, forKey: key) } else { defaults.removeObject(forKey: key) } } defer { for (key, value) in snapshot { if let value { defaults.set(value, forKey: key) } else { defaults.removeObject(forKey: key) } } } return try await body() } private func withKeychainValues(_ updates: [KeychainEntry: String?], _ body: () throws -> T) rethrows -> T { var snapshot: [KeychainEntry: String?] = [:] for entry in updates.keys { snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account) } for (entry, value) in updates { if let value { _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) } else { _ = KeychainStore.delete(service: entry.service, account: entry.account) } } defer { for (entry, value) in snapshot { if let value { _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) } else { _ = KeychainStore.delete(service: entry.service, account: entry.account) } } } return try body() } @MainActor private func withKeychainValues( _ updates: [KeychainEntry: String?], _ body: () async throws -> T) async rethrows -> T { var snapshot: [KeychainEntry: String?] = [:] for entry in updates.keys { snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account) } for (entry, value) in updates { if let value { _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) } else { _ = KeychainStore.delete(service: entry.service, account: entry.account) } } defer { for (entry, value) in snapshot { if let value { _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) } else { _ = KeychainStore.delete(service: entry.service, account: entry.account) } } } return try await body() } @Suite(.serialized) struct BridgeConnectionControllerTests { @Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() { let defaults = UserDefaults.standard let displayKey = "node.displayName" withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) { withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) { let appModel = NodeAppModel() let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false) let resolved = controller._test_resolvedDisplayName(defaults: defaults) #expect(!resolved.isEmpty) #expect(defaults.string(forKey: displayKey) == resolved) } } } @Test @MainActor func resolvedDisplayNamePreservesCustomValue() { let defaults = UserDefaults.standard let displayKey = "node.displayName" withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) { withUserDefaults([displayKey: "My iOS Node", "node.instanceId": "ios-test"]) { let appModel = NodeAppModel() let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false) let resolved = controller._test_resolvedDisplayName(defaults: defaults) #expect(resolved == "My iOS Node") #expect(defaults.string(forKey: displayKey) == "My iOS Node") } } } @Test @MainActor func makeHelloBuildsCapsAndCommands() { let voiceWakeKey = VoiceWakePreferences.enabledKey withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) { withUserDefaults([ "node.instanceId": "ios-test", "node.displayName": "Test Node", "camera.enabled": false, voiceWakeKey: true, ]) { let appModel = NodeAppModel() let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false) let hello = controller._test_makeHello(token: "token-123") #expect(hello.nodeId == "ios-test") #expect(hello.displayName == "Test Node") #expect(hello.token == "token-123") let caps = Set(hello.caps ?? []) #expect(caps.contains(ClawdbotCapability.canvas.rawValue)) #expect(caps.contains(ClawdbotCapability.screen.rawValue)) #expect(caps.contains(ClawdbotCapability.voiceWake.rawValue)) #expect(!caps.contains(ClawdbotCapability.camera.rawValue)) let commands = Set(hello.commands ?? []) #expect(commands.contains(ClawdbotCanvasCommand.present.rawValue)) #expect(commands.contains(ClawdbotScreenCommand.record.rawValue)) #expect(!commands.contains(ClawdbotCameraCommand.snap.rawValue)) #expect(!(hello.platform ?? "").isEmpty) #expect(!(hello.deviceFamily ?? "").isEmpty) #expect(!(hello.modelIdentifier ?? "").isEmpty) #expect(!(hello.version ?? "").isEmpty) } } } @Test @MainActor func makeHelloIncludesCameraCommandsWhenEnabled() { withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) { withUserDefaults([ "node.instanceId": "ios-test", "node.displayName": "Test Node", "camera.enabled": true, VoiceWakePreferences.enabledKey: false, ]) { let appModel = NodeAppModel() let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false) let hello = controller._test_makeHello(token: "token-456") let caps = Set(hello.caps ?? []) #expect(caps.contains(ClawdbotCapability.camera.rawValue)) let commands = Set(hello.commands ?? []) #expect(commands.contains(ClawdbotCameraCommand.snap.rawValue)) #expect(commands.contains(ClawdbotCameraCommand.clip.rawValue)) } } } @Test @MainActor func autoConnectRefreshesTokenOnUnauthorized() async { let bridge = BridgeDiscoveryModel.DiscoveredBridge( name: "Gateway", endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790), stableID: "bridge-1", debugID: "bridge-debug", lanHost: "Mac.local", tailnetDns: nil, gatewayPort: 18789, bridgePort: 18790, canvasPort: 18793, tlsEnabled: false, tlsFingerprintSha256: nil, cliPath: nil) let mock = MockBridgePairingClient(resultToken: "new-token") let account = "bridge-token.ios-test" await withKeychainValues([ instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil, KeychainEntry(service: bridgeService, account: account): "old-token", ]) { await withUserDefaults([ "node.instanceId": "ios-test", "bridge.lastDiscoveredStableID": "bridge-1", "bridge.manual.enabled": false, ]) { let appModel = NodeAppModel() let controller = BridgeConnectionController( appModel: appModel, startDiscovery: false, bridgeClientFactory: { mock }) controller._test_setBridges([bridge]) controller._test_triggerAutoConnect() for _ in 0..<20 { if appModel.connectedBridgeID == bridge.stableID { break } try? await Task.sleep(nanoseconds: 50_000_000) } #expect(appModel.connectedBridgeID == bridge.stableID) let stored = KeychainStore.loadString(service: bridgeService, account: account) #expect(stored == "new-token") let lastToken = await mock.lastToken #expect(lastToken == "old-token") } } } @Test @MainActor func autoConnectPrefersPreferredBridgeOverLastDiscovered() async { let bridgeA = BridgeDiscoveryModel.DiscoveredBridge( name: "Gateway A", endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790), stableID: "bridge-1", debugID: "bridge-a", lanHost: "MacA.local", tailnetDns: nil, gatewayPort: 18789, bridgePort: 18790, canvasPort: 18793, tlsEnabled: false, tlsFingerprintSha256: nil, cliPath: nil) let bridgeB = BridgeDiscoveryModel.DiscoveredBridge( name: "Gateway B", endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 28790), stableID: "bridge-2", debugID: "bridge-b", lanHost: "MacB.local", tailnetDns: nil, gatewayPort: 28789, bridgePort: 28790, canvasPort: 28793, tlsEnabled: false, tlsFingerprintSha256: nil, cliPath: nil) let mock = MockBridgePairingClient(resultToken: "token-ok") let account = "bridge-token.ios-test" await withKeychainValues([ instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil, KeychainEntry(service: bridgeService, account: account): "old-token", ]) { await withUserDefaults([ "node.instanceId": "ios-test", "bridge.preferredStableID": "bridge-2", "bridge.lastDiscoveredStableID": "bridge-1", "bridge.manual.enabled": false, ]) { let appModel = NodeAppModel() let controller = BridgeConnectionController( appModel: appModel, startDiscovery: false, bridgeClientFactory: { mock }) controller._test_setBridges([bridgeA, bridgeB]) controller._test_triggerAutoConnect() for _ in 0..<20 { if appModel.connectedBridgeID == bridgeB.stableID { break } try? await Task.sleep(nanoseconds: 50_000_000) } #expect(appModel.connectedBridgeID == bridgeB.stableID) } } } }