openclaw/apps/ios/Tests/GatewayConnectionControllerTests.swift
Chris Herold 6e33f3f0f3
iOS: implement dual-connection gateway architecture with deadlock fix
Aligns the iOS app with the Clawnet refactor by implementing proper role
separation for gateway connections. Uses separate operator and node sessions
to match the gateway's authorization requirements.

Changes:
- New GatewayOperatorSession: Wraps GatewayChannelActor for operator-role
  RPC requests (chat.*, health, sessions.list) without invoke handling
- Dual-connection architecture: Operator session for requests, node session
  for node.event calls (e.g., chat.subscribe)
- Separate websocket sessions: Each connection gets its own URLSession to
  prevent response cross-talk
- Updated chat transport: IOSGatewayChatTransport uses operator session for
  requests, node session for subscriptions

ClawdbotKit (shared):
- Deadlock fix in GatewayChannel.swift: Moved connection finalization
  (listen(), connected=true, isConnecting=false, waiter resumption) to occur
  before calling pushHandler. This fixes a latent bug where requests made
  from onConnected callbacks would deadlock. Does not affect macOS (its
  callback doesn't make requests).
- Package.swift: Fixed argument order for Swift 6.2 compatibility

iOS chat is now working. This is the base PR to unlock further work on
the iOS app.
2026-01-27 14:46:27 -08:00

115 lines
4.9 KiB
Swift

import MoltbotKit
import Foundation
import Testing
import UIKit
@testable import Moltbot
private func withUserDefaults<T>(_ 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()
}
@Suite(.serialized) struct GatewayConnectionControllerTests {
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
let defaults = UserDefaults.standard
let displayKey = "node.displayName"
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
#expect(!resolved.isEmpty)
#expect(defaults.string(forKey: displayKey) == resolved)
}
}
@Test @MainActor func currentCapsReflectToggles() {
withUserDefaults([
"node.instanceId": "ios-test",
"node.displayName": "Test Node",
"camera.enabled": true,
"location.enabledMode": MoltbotLocationMode.always.rawValue,
VoiceWakePreferences.enabledKey: true,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let caps = Set(controller._test_currentCaps())
#expect(caps.contains(MoltbotCapability.canvas.rawValue))
#expect(caps.contains(MoltbotCapability.screen.rawValue))
#expect(caps.contains(MoltbotCapability.camera.rawValue))
#expect(caps.contains(MoltbotCapability.location.rawValue))
#expect(caps.contains(MoltbotCapability.voiceWake.rawValue))
}
}
@Test @MainActor func currentCommandsIncludeLocationWhenEnabled() {
withUserDefaults([
"node.instanceId": "ios-test",
"location.enabledMode": MoltbotLocationMode.whileUsing.rawValue,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
#expect(commands.contains(MoltbotLocationCommand.get.rawValue))
}
}
@Test @MainActor func currentCommandsMatchGatewayIOSAllowlist() {
// Verify only iOS-allowlisted commands are declared.
// iOS allowlist per node-command-policy.ts: canvas, camera, screen, location
// System commands (run, which, notify, execApprovals.*) are NOT allowed on iOS.
withUserDefaults([
"node.instanceId": "ios-test",
"camera.enabled": true,
"location.enabledMode": MoltbotLocationMode.whileUsing.rawValue,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
#expect(commands.contains(MoltbotCanvasCommand.present.rawValue))
#expect(commands.contains(MoltbotCanvasCommand.hide.rawValue))
#expect(commands.contains(MoltbotCanvasCommand.navigate.rawValue))
#expect(commands.contains(MoltbotCanvasCommand.evalJS.rawValue))
#expect(commands.contains(MoltbotCanvasCommand.snapshot.rawValue))
#expect(commands.contains(MoltbotCanvasA2UICommand.push.rawValue))
#expect(commands.contains(MoltbotCanvasA2UICommand.pushJSONL.rawValue))
#expect(commands.contains(MoltbotCanvasA2UICommand.reset.rawValue))
#expect(commands.contains(MoltbotScreenCommand.record.rawValue))
#expect(commands.contains(MoltbotCameraCommand.list.rawValue))
#expect(commands.contains(MoltbotCameraCommand.snap.rawValue))
#expect(commands.contains(MoltbotCameraCommand.clip.rawValue))
#expect(commands.contains(MoltbotLocationCommand.get.rawValue))
#expect(!commands.contains(MoltbotSystemCommand.notify.rawValue))
#expect(!commands.contains(MoltbotSystemCommand.which.rawValue))
#expect(!commands.contains(MoltbotSystemCommand.run.rawValue))
#expect(!commands.contains(MoltbotSystemCommand.execApprovalsGet.rawValue))
#expect(!commands.contains(MoltbotSystemCommand.execApprovalsSet.rawValue))
}
}
}