Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
import SwiftUI
import Testing
@testable import OpenClaw
@Suite struct AppCoverageTests {
@Test @MainActor func nodeAppModelUpdatesBackgroundedState() {
let appModel = NodeAppModel()
appModel.setScenePhase(.background)
#expect(appModel.isBackgrounded == true)
appModel.setScenePhase(.inactive)
#expect(appModel.isBackgrounded == false)
appModel.setScenePhase(.active)
#expect(appModel.isBackgrounded == false)
}
@Test @MainActor func voiceWakeStartReportsUnsupportedOnSimulator() async {
let voiceWake = VoiceWakeManager()
voiceWake.isEnabled = true
await voiceWake.start()
#expect(voiceWake.isListening == false)
#expect(voiceWake.statusText.contains("Simulator"))
voiceWake.stop()
#expect(voiceWake.statusText == "Off")
}
}

View File

@@ -0,0 +1,74 @@
import Foundation
import Testing
@testable import OpenClaw
struct BackgroundAliveBeaconTests {
@Test func `normalize trigger accepts closed reasons`() {
#expect(BackgroundAliveBeacon.normalizeTrigger("silent_push") == .silentPush)
#expect(BackgroundAliveBeacon.normalizeTrigger(" bg_app_refresh ") == .bgAppRefresh)
#expect(BackgroundAliveBeacon.normalizeTrigger("SIGNIFICANT_LOCATION") == .significantLocation)
}
@Test func `normalize trigger falls back to background`() {
#expect(BackgroundAliveBeacon.normalizeTrigger("watch_prompt_action") == .background)
#expect(BackgroundAliveBeacon.normalizeTrigger("") == .background)
}
@Test func `recent success throttle uses milliseconds`() {
let now = Date(timeIntervalSince1970: 1000)
#expect(BackgroundAliveBeacon.shouldSkipRecentSuccess(
isGatewayConnected: true,
now: now,
lastSuccessAtMs: 999_500,
minInterval: 10))
#expect(!BackgroundAliveBeacon.shouldSkipRecentSuccess(
isGatewayConnected: true,
now: now,
lastSuccessAtMs: 980_000,
minInterval: 10))
}
@Test func `recent success throttle does not suppress disconnected wakes`() {
let now = Date(timeIntervalSince1970: 1000)
#expect(!BackgroundAliveBeacon.shouldSkipRecentSuccess(
isGatewayConnected: false,
now: now,
lastSuccessAtMs: 999_500,
minInterval: 10))
}
@Test func `make node event payload wraps presence payload JSON`() throws {
let payload = BackgroundAliveBeacon.Payload(
trigger: BackgroundAliveBeacon.Trigger.silentPush.rawValue,
sentAtMs: 123,
displayName: "Peter's iPhone",
version: "2026.4.28",
platform: "iOS 18.4.0",
deviceFamily: "iPhone",
modelIdentifier: "iPhone17,1",
pushTransport: "relay")
let requestJSON = try BackgroundAliveBeacon.makeNodeEventRequestPayloadJSON(payload: payload)
let requestData = try #require(requestJSON.data(using: .utf8))
let request = try JSONDecoder().decode(
BackgroundAliveBeacon.NodeEventRequestPayload.self,
from: requestData)
#expect(request.event == "node.presence.alive")
let payloadData = try #require(request.payloadJSON.data(using: .utf8))
let decodedPayload = try #require(JSONSerialization.jsonObject(with: payloadData) as? [String: Any])
let sentAtMs = try #require(decodedPayload["sentAtMs"] as? Int)
#expect(decodedPayload["trigger"] as? String == "silent_push")
#expect(sentAtMs == 123)
#expect(decodedPayload["pushTransport"] as? String == "relay")
}
@Test func `old gateway ack does not count as handled`() throws {
let data = try #require(#"{"ok":true}"#.data(using: .utf8))
let response = try #require(BackgroundAliveBeacon.decodeResponse(data))
#expect(response.ok == true)
#expect(response.handled == nil)
}
}

View File

@@ -0,0 +1,24 @@
import Testing
@testable import OpenClaw
@Suite struct CameraControllerClampTests {
@Test func clampQualityDefaultsAndBounds() {
#expect(CameraController.clampQuality(nil) == 0.9)
#expect(CameraController.clampQuality(0.0) == 0.05)
#expect(CameraController.clampQuality(0.049) == 0.05)
#expect(CameraController.clampQuality(0.05) == 0.05)
#expect(CameraController.clampQuality(0.5) == 0.5)
#expect(CameraController.clampQuality(1.0) == 1.0)
#expect(CameraController.clampQuality(1.1) == 1.0)
}
@Test func clampDurationDefaultsAndBounds() {
#expect(CameraController.clampDurationMs(nil) == 3000)
#expect(CameraController.clampDurationMs(0) == 250)
#expect(CameraController.clampDurationMs(249) == 250)
#expect(CameraController.clampDurationMs(250) == 250)
#expect(CameraController.clampDurationMs(1000) == 1000)
#expect(CameraController.clampDurationMs(60000) == 60000)
#expect(CameraController.clampDurationMs(60001) == 60000)
}
}

View File

@@ -0,0 +1,14 @@
import Testing
@testable import OpenClaw
@Suite struct CameraControllerErrorTests {
@Test func errorDescriptionsAreStable() {
#expect(CameraController.CameraError.cameraUnavailable.errorDescription == "Camera unavailable")
#expect(CameraController.CameraError.microphoneUnavailable.errorDescription == "Microphone unavailable")
#expect(CameraController.CameraError.permissionDenied(kind: "Camera")
.errorDescription == "Camera permission denied")
#expect(CameraController.CameraError.invalidParams("bad").errorDescription == "bad")
#expect(CameraController.CameraError.captureFailed("nope").errorDescription == "nope")
#expect(CameraController.CameraError.exportFailed("export").errorDescription == "export")
}
}

View File

@@ -0,0 +1,27 @@
import SwiftUI
import Testing
@testable import OpenClaw
@MainActor
@Suite struct CommandCenterTabLayoutTests {
@Test func splitLayoutDisabledForCompactWidth() {
#expect(
!CommandCenterTab.usesSplitSectionsLayout(
horizontalSizeClass: .compact,
containerWidth: 1_200))
}
@Test func splitLayoutDisabledBelowWidthThreshold() {
#expect(
!CommandCenterTab.usesSplitSectionsLayout(
horizontalSizeClass: .regular,
containerWidth: 900))
}
@Test func splitLayoutEnabledForRegularWideLayout() {
#expect(
CommandCenterTab.usesSplitSectionsLayout(
horizontalSizeClass: .regular,
containerWidth: 1_024))
}
}

View File

@@ -0,0 +1,41 @@
import Testing
@testable import OpenClaw
struct CommandCenterTabSessionFilterTests {
@Test func `hides direct agent device sessions`() {
#expect(!CommandCenterTab.isRecentChatSession("main", defaultSessionKey: "main"))
#expect(!CommandCenterTab.isRecentChatSession("agent:main:main", defaultSessionKey: "main"))
#expect(!CommandCenterTab.isRecentChatSession("agent:rust-claw:main", defaultSessionKey: "main"))
#expect(!CommandCenterTab.isRecentChatSession("agent:main:node-0b88d67b7e42", defaultSessionKey: "main"))
#expect(!CommandCenterTab.isRecentChatSession("agent:main:work", defaultSessionKey: "work"))
#expect(!CommandCenterTab.isRecentChatSession("main", defaultSessionKey: "agent:rust-claw:work"))
#expect(!CommandCenterTab.isRecentChatSession("global", defaultSessionKey: "agent:rust-claw:work"))
#expect(!CommandCenterTab.isRecentChatSession("node-0b88d67b7e42", defaultSessionKey: "agent:rust-claw:work"))
#expect(!CommandCenterTab.isRecentChatSession("work", defaultSessionKey: "agent:rust-claw:work"))
#expect(!CommandCenterTab.isRecentChatSession("agent:main:work", defaultSessionKey: "agent:rust-claw:work"))
#expect(!CommandCenterTab.isRecentChatSession("agent:main:main:thread:42", defaultSessionKey: "main"))
#expect(!CommandCenterTab.isRecentChatSession("agent:support:main:thread:1234:42", defaultSessionKey: "main"))
#expect(!CommandCenterTab.isRecentChatSession(
"agent:main:node-0b88d67b7e42:thread:42",
defaultSessionKey: "main"))
#expect(!CommandCenterTab.isRecentChatSession("agent:main:work:thread:42", defaultSessionKey: "work"))
#expect(!CommandCenterTab.isRecentChatSession(
"agent:main:work:thread:42",
defaultSessionKey: "agent:rust-claw:work"))
}
@Test func `keeps agent scoped channel and cron sessions`() {
#expect(CommandCenterTab.isRecentChatSession(
"agent:main:signal:direct:+15555550123",
defaultSessionKey: "main"))
#expect(CommandCenterTab.isRecentChatSession(
"agent:rust-claw:mattermost:channel:abc123",
defaultSessionKey: "main"))
#expect(CommandCenterTab.isRecentChatSession(
"agent:rust-claw:cron:3cd2eb6f-b8a5-4db7-b74a-f6a3f7eab3d3",
defaultSessionKey: "main"))
#expect(CommandCenterTab.isRecentChatSession(
"agent:main:slack:channel:c1:thread:123",
defaultSessionKey: "main"))
}
}

View File

@@ -0,0 +1,227 @@
import OpenClawKit
import Foundation
import Testing
private func setupCode(from payload: String) -> String {
Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func agentAction(
message: String,
sessionKey: String? = nil,
thinking: String? = nil,
deliver: Bool = false,
to: String? = nil,
channel: String? = nil,
timeoutSeconds: Int? = nil,
key: String? = nil) -> DeepLinkRoute
{
.agent(
.init(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: deliver,
to: to,
channel: channel,
timeoutSeconds: timeoutSeconds,
key: key))
}
@Suite struct DeepLinkParserTests {
@Test func parseRejectsUnknownHost() {
let url = URL(string: "openclaw://nope?message=hi")!
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseHostIsCaseInsensitive() {
let url = URL(string: "openclaw://AGENT?message=Hello")!
#expect(DeepLinkParser.parse(url) == agentAction(message: "Hello"))
}
@Test func parseRejectsNonOpenClawScheme() {
let url = URL(string: "https://example.com/agent?message=hi")!
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseRejectsEmptyMessage() {
let url = URL(string: "openclaw://agent?message=%20%20%0A")!
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseAgentLinkParsesCommonFields() {
let url =
URL(string: "openclaw://agent?message=Hello&deliver=1&sessionKey=node-test&thinking=low&timeoutSeconds=30")!
#expect(DeepLinkParser.parse(url) == agentAction(
message: "Hello",
sessionKey: "node-test",
thinking: "low",
deliver: true,
timeoutSeconds: 30))
}
@Test func parseAgentLinkParsesTargetRoutingFields() {
let url =
URL(
string: "openclaw://agent?message=Hello%20World&deliver=1&to=%2B15551234567&channel=whatsapp&key=secret")!
#expect(DeepLinkParser.parse(url) == agentAction(
message: "Hello World",
deliver: true,
to: "+15551234567",
channel: "whatsapp",
key: "secret"))
}
@Test func parseRejectsNegativeTimeoutSeconds() {
let url = URL(string: "openclaw://agent?message=Hello&timeoutSeconds=-1")!
#expect(DeepLinkParser.parse(url) == agentAction(message: "Hello"))
}
@Test func parseGatewayLinkParsesCommonFields() {
let url = URL(
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
#expect(
DeepLinkParser.parse(url) == .gateway(
.init(
host: "openclaw.local",
port: 18789,
tls: true,
bootstrapToken: nil,
token: "abc",
password: "def")))
}
@Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() {
let url = URL(
string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")!
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseGatewayLinkAllowsPrivateLanWs() {
let url = URL(
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=0&token=abc")!
#expect(
DeepLinkParser.parse(url) == .gateway(
.init(
host: "openclaw.local",
port: 18789,
tls: false,
bootstrapToken: nil,
token: "abc",
password: nil)))
}
@Test func parseGatewayLinkRejectsInsecurePrefixBypassHost() {
let url = URL(
string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")!
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
let payload = #"{"url":"wss://gateway.example.com:443","bootstrapToken":"tok","password":"pw"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
bootstrapToken: "tok",
token: nil,
password: "pw"))
}
@Test func parseGatewaySetupCodeRejectsInvalidInput() {
#expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil)
}
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() {
let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() {
let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupCodeAllowsLoopbackWs() {
let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "127.0.0.1",
port: 18789,
tls: false,
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func parseGatewaySetupCodeAllowsPrivateLanWs() {
let payload = #"{"url":"ws://openclaw.local:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "openclaw.local",
port: 18789,
tls: false,
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func parseGatewaySetupCodeRejectsTailnetPlaintextWs() {
let payload = #"{"url":"ws://gateway.tailnet.ts.net:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupInputParsesFullCopiedSetupMessage() {
let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupInput("""
Pairing setup code generated.
Setup code:
\(setupCode(from: payload))
""")
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func parseGatewaySetupInputParsesRawGatewayURL() {
let link = GatewayConnectDeepLink.fromSetupInput("wss://gateway.example.com:444")
#expect(link == .init(
host: "gateway.example.com",
port: 444,
tls: true,
bootstrapToken: nil,
token: nil,
password: nil))
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
import Testing
@testable import OpenClaw
struct DeviceInfoHelperTests {
@Test func `iOS version display omits platform prefix`() {
let version = OperatingSystemVersion(majorVersion: 26, minorVersion: 5, patchVersion: 0)
#expect(DeviceInfoHelper.iOSVersionStringForDisplay(version) == "26.5.0")
}
}

View File

@@ -0,0 +1,108 @@
import Foundation
import Testing
import UserNotifications
@testable import OpenClaw
private final class MockNotificationCenter: NotificationCentering, @unchecked Sendable {
var authorization: NotificationAuthorizationStatus = .authorized
var addedRequests: [UNNotificationRequest] = []
var pendingRemovedIdentifiers: [[String]] = []
var deliveredRemovedIdentifiers: [[String]] = []
var delivered: [NotificationSnapshot] = []
func authorizationStatus() async -> NotificationAuthorizationStatus {
self.authorization
}
func add(_ request: UNNotificationRequest) async throws {
self.addedRequests.append(request)
}
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async {
self.pendingRemovedIdentifiers.append(identifiers)
}
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async {
self.deliveredRemovedIdentifiers.append(identifiers)
}
func deliveredNotifications() async -> [NotificationSnapshot] {
self.delivered
}
}
@Suite(.serialized) struct ExecApprovalNotificationBridgeTests {
@Test func parsePromptMapsDefaultNotificationTap() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-123",
],
])
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-123"))
}
@Test func parsePromptMapsReviewAction() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: ExecApprovalNotificationBridge.reviewActionIdentifier,
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-456",
],
])
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-456"))
}
@Test func parsePromptIgnoresUnexpectedActionIdentifiers() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: "openclaw.exec-approval.allow-once",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-789",
],
])
#expect(prompt == nil)
}
@Test @MainActor func handleResolvedPushRemovesMatchingNotifications() async {
let center = MockNotificationCenter()
center.delivered = [
NotificationSnapshot(
identifier: "remote-approval-1",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-123",
],
]),
NotificationSnapshot(
identifier: "remote-other",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-999",
],
]),
]
let handled = await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.resolvedKind,
"approvalId": "approval-123",
],
],
notificationCenter: center)
#expect(handled)
#expect(center.pendingRemovedIdentifiers == [["exec.approval.approval-123"]])
#expect(center.deliveredRemovedIdentifiers == [["remote-approval-1"]])
}
}

View File

@@ -0,0 +1,452 @@
import Foundation
import Testing
import UIKit
@testable import OpenClawKit
@testable import OpenClaw
@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": OpenClawLocationMode.always.rawValue,
VoiceWakePreferences.enabledKey: true,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let caps = Set(controller._test_currentCaps())
#expect(caps.contains(OpenClawCapability.canvas.rawValue))
#expect(caps.contains(OpenClawCapability.screen.rawValue))
#expect(caps.contains(OpenClawCapability.camera.rawValue))
#expect(caps.contains(OpenClawCapability.location.rawValue))
#expect(caps.contains(OpenClawCapability.voiceWake.rawValue))
#expect(caps.contains(OpenClawCapability.talk.rawValue))
}
}
@Test @MainActor func currentCommandsIncludeLocationWhenEnabled() {
withUserDefaults([
"node.instanceId": "ios-test",
"location.enabledMode": OpenClawLocationMode.whileUsing.rawValue,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
#expect(commands.contains(OpenClawLocationCommand.get.rawValue))
}
}
@Test @MainActor func locationPermissionRequiresGlobalServicesAndAppAuthorization() {
#expect(GatewayConnectionController._test_isLocationAvailable(
servicesEnabled: true,
status: .authorizedWhenInUse))
#expect(GatewayConnectionController._test_isLocationAvailable(
servicesEnabled: true,
status: .authorizedAlways))
#expect(!GatewayConnectionController._test_isLocationAvailable(
servicesEnabled: false,
status: .authorizedAlways))
#expect(!GatewayConnectionController._test_isLocationAvailable(
servicesEnabled: true,
status: .denied))
}
@Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() {
withUserDefaults([
"node.instanceId": "ios-test",
"camera.enabled": true,
"location.enabledMode": OpenClawLocationMode.whileUsing.rawValue,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
// iOS should expose notify, but not host shell/exec-approval commands.
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.which.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue))
}
}
@Test @MainActor func operatorConnectOptionsOnlyRequestApprovalScopeWhenEnabled() {
let appModel = NodeAppModel()
let withoutApprovalScope = appModel._test_makeOperatorConnectOptions(
clientId: "openclaw-ios",
displayName: "OpenClaw iOS",
includeApprovalScope: false)
let withApprovalScope = appModel._test_makeOperatorConnectOptions(
clientId: "openclaw-ios",
displayName: "OpenClaw iOS",
includeApprovalScope: true)
let withAdminScope = appModel._test_makeOperatorConnectOptions(
clientId: "openclaw-ios",
displayName: "OpenClaw iOS",
includeAdminScope: true,
includeApprovalScope: false)
#expect(withoutApprovalScope.role == "operator")
#expect(!withoutApprovalScope.scopes.contains("operator.admin"))
#expect(withoutApprovalScope.scopes.contains("operator.read"))
#expect(withoutApprovalScope.scopes.contains("operator.write"))
#expect(!withoutApprovalScope.scopes.contains("operator.approvals"))
#expect(withoutApprovalScope.scopes.contains("operator.talk.secrets"))
#expect(!withoutApprovalScope.scopesAreExplicit)
#expect(withApprovalScope.scopes.contains("operator.approvals"))
#expect(withAdminScope.scopes.contains("operator.admin"))
}
@Test @MainActor func operatorTalkPermissionUpgradeUsesExplicitLeastPrivilegeScopes() {
let appModel = NodeAppModel()
let options = appModel._test_makeOperatorConnectOptions(
clientId: "openclaw-ios",
displayName: "OpenClaw iOS",
includeApprovalScope: false,
forceExplicitScopes: true)
#expect(options.scopesAreExplicit)
#expect(!options.scopes.contains("operator.admin"))
#expect(!options.scopes.contains("operator.approvals"))
#expect(options.scopes.contains("operator.read"))
#expect(options.scopes.contains("operator.write"))
#expect(options.scopes.contains("operator.talk.secrets"))
}
@Test func operatorAdminScopeRequestsOnlyWhenSharedAuthOrAlreadyGranted() {
#expect(
!NodeAppModel._test_shouldRequestOperatorAdminScope(
token: nil,
password: nil,
storedOperatorScopes: ["operator.read", "operator.write", "operator.talk.secrets"]))
#expect(
NodeAppModel._test_shouldRequestOperatorAdminScope(
token: nil,
password: nil,
storedOperatorScopes: ["operator.admin"]))
#expect(
NodeAppModel._test_shouldRequestOperatorAdminScope(
token: "shared-token",
password: nil,
storedOperatorScopes: []))
#expect(
NodeAppModel._test_shouldRequestOperatorAdminScope(
token: nil,
password: "shared-password",
storedOperatorScopes: []))
#expect(
!NodeAppModel._test_shouldRequestOperatorAdminScope(
token: "shared-token",
password: nil,
storedOperatorScopes: [],
forceTalkPermissionUpgradeRequest: true))
}
@Test func storedDeviceTokenScopeGapUsesGatewayScopeCompatibility() {
#expect(!GatewayChannelActor._test_requestedScopesExceedStoredToken(
role: "operator",
requestedScopes: ["operator.read", "operator.write", "operator.talk.secrets"],
storedToken: "stored-device-token",
storedScopes: ["operator.admin"]))
#expect(!GatewayChannelActor._test_requestedScopesExceedStoredToken(
role: "operator",
requestedScopes: ["operator.read"],
storedToken: "stored-device-token",
storedScopes: []))
#expect(GatewayChannelActor._test_requestedScopesExceedStoredToken(
role: "operator",
requestedScopes: ["operator.admin"],
storedToken: "stored-device-token",
storedScopes: ["operator.read"]))
}
@Test func operatorApprovalScopeRequestsStayBackwardCompatible() {
#expect(
!NodeAppModel._test_shouldRequestOperatorApprovalScope(
token: nil,
password: nil,
storedOperatorScopes: ["operator.read", "operator.write", "operator.talk.secrets"]))
#expect(
NodeAppModel._test_shouldRequestOperatorApprovalScope(
token: nil,
password: nil,
storedOperatorScopes: [
"operator.approvals",
"operator.read",
"operator.write",
"operator.talk.secrets",
]))
#expect(
NodeAppModel._test_shouldRequestOperatorApprovalScope(
token: "shared-token",
password: nil,
storedOperatorScopes: []))
#expect(
!NodeAppModel._test_shouldRequestOperatorApprovalScope(
token: "shared-token",
password: nil,
storedOperatorScopes: [],
forceTalkPermissionUpgradeRequest: true))
#expect(
NodeAppModel._test_shouldRequestOperatorApprovalScope(
token: nil,
password: nil,
storedOperatorScopes: ["operator.approvals"],
forceTalkPermissionUpgradeRequest: true))
}
@Test @MainActor func operatorPairingProblemPreservesPrimaryGatewayConnectionState() {
let appModel = NodeAppModel()
appModel._test_setGatewayConnected(true)
appModel.gatewayServerName = "gateway.example.com"
appModel.gatewayRemoteAddress = "127.0.0.1:53380"
let problem = GatewayConnectionProblem(
kind: .pairingScopeUpgradeRequired,
owner: .gateway,
title: "Additional permissions required",
message: "Approve the requested permissions on the gateway.",
requestId: "req-admin",
retryable: false,
pauseReconnect: true)
appModel._test_applyOperatorGatewayConnectionProblem(problem)
#expect(appModel._test_isGatewayConnected())
#expect(appModel.gatewayServerName == "gateway.example.com")
#expect(appModel.gatewayRemoteAddress == "127.0.0.1:53380")
#expect(appModel.lastGatewayProblem == problem)
#expect(appModel.gatewayPairingPaused)
#expect(appModel.gatewayPairingRequestId == "req-admin")
appModel._test_clearGatewayConnectionProblem()
#expect(appModel.lastGatewayProblem == problem)
#expect(appModel.gatewayPairingPaused)
#expect(appModel.gatewayPairingRequestId == "req-admin")
appModel._test_clearOperatorGatewayConnectionProblemIfCurrent()
#expect(appModel._test_isGatewayConnected())
#expect(appModel.gatewayServerName == "gateway.example.com")
#expect(appModel.lastGatewayProblem == nil)
#expect(!appModel.gatewayPairingPaused)
#expect(appModel.gatewayPairingRequestId == nil)
#expect(appModel.gatewayStatusText == "Connected")
}
@Test @MainActor func savedManualEndpointFallbackUsesOnboardingHostWhenAutoConnectIsEnabled() {
withUserDefaults([
"gateway.autoconnect": true,
"gateway.manual.enabled": true,
"gateway.manual.host": "forges-mac-mini.taila96df5.ts.net",
"gateway.manual.port": 0,
"gateway.manual.tls": false,
"node.instanceId": "ios-test",
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let endpoint = controller._test_savedManualEndpointFallback()
#expect(endpoint?.host == "forges-mac-mini.taila96df5.ts.net")
#expect(endpoint?.port == 443)
#expect(endpoint?.useTLS == true)
}
}
@Test @MainActor func savedManualEndpointFallbackRequiresManualGatewayEnabled() {
withUserDefaults([
"gateway.autoconnect": true,
"gateway.manual.enabled": false,
"gateway.manual.host": "forges-mac-mini.taila96df5.ts.net",
"gateway.manual.port": 443,
"gateway.manual.tls": true,
"node.instanceId": "ios-test",
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
#expect(controller._test_savedManualEndpointFallback() == nil)
}
}
@Test @MainActor func savedManualEndpointFallbackRequiresAutoConnect() {
withUserDefaults([
"gateway.autoconnect": false,
"gateway.manual.enabled": true,
"gateway.manual.host": "forges-mac-mini.taila96df5.ts.net",
"gateway.manual.port": 443,
"gateway.manual.tls": true,
"node.instanceId": "ios-test",
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
#expect(controller._test_savedManualEndpointFallback() == nil)
}
}
@Test func gatewayConnectConfigMatchesEquivalentInputs() {
let lhs = Self.makeGatewayConnectConfig()
let rhs = GatewayConnectConfig(
url: lhs.url,
stableID: lhs.stableID,
tls: lhs.tls,
token: lhs.token,
bootstrapToken: lhs.bootstrapToken,
password: lhs.password,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["canvas", "screen"],
commands: ["location.get", "notify"],
permissions: ["screen": true],
clientId: "ios",
clientMode: "node",
clientDisplayName: "Phone"))
#expect(lhs.hasSameConnectionInputs(as: rhs))
}
@Test @MainActor func applyingDifferentGatewayConfigReconnectsActiveTasks() {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let first = Self.makeGatewayConnectConfig(
url: URL(string: "wss://first.gateway.example.com")!,
stableID: "manual|first.gateway.example.com|443")
let second = Self.makeGatewayConnectConfig(
url: URL(string: "wss://second.gateway.example.com")!,
stableID: "manual|second.gateway.example.com|443")
appModel.applyGatewayConnectConfig(first)
appModel.applyGatewayConnectConfig(second)
#expect(appModel.connectedGatewayID == second.stableID)
}
@Test @MainActor func forcedReconnectResetClearsActiveGatewayLoopTasks() async {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig())
#expect(appModel._test_hasGatewayLoopTasks().node)
#expect(appModel._test_hasGatewayLoopTasks().operator)
await appModel.resetGatewaySessionsForForcedReconnect()
#expect(!appModel._test_hasGatewayLoopTasks().node)
#expect(!appModel._test_hasGatewayLoopTasks().operator)
}
@Test @MainActor func foregroundStaleConnectionRestartReappliesActiveGatewayConfig() async {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let config = Self.makeGatewayConnectConfig()
appModel.applyGatewayConnectConfig(config)
await appModel._test_restartGatewaySessionsAfterForegroundStaleConnection()
#expect(appModel.gatewayStatusText == "Reconnecting…")
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: config) == true)
#expect(appModel._test_hasGatewayLoopTasks().node)
#expect(appModel._test_hasGatewayLoopTasks().operator)
}
@Test @MainActor func loadLastConnectionReadsSavedValues() {
let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
defer {
if let prior {
_ = KeychainStore.saveString(
prior,
service: "ai.openclawfoundation.app.gateway",
account: "lastConnection")
} else {
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
}
}
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "gateway.example.com",
port: 443,
useTLS: true,
stableID: "manual|gateway.example.com|443")
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(
host: "gateway.example.com",
port: 443,
useTLS: true,
stableID: "manual|gateway.example.com|443"))
}
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
defer {
if let prior {
_ = KeychainStore.saveString(
prior,
service: "ai.openclawfoundation.app.gateway",
account: "lastConnection")
} else {
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
}
}
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
// Plant legacy UserDefaults with invalid host/port to exercise migration + validation.
withUserDefaults([
"gateway.last.kind": "manual",
"gateway.last.host": "",
"gateway.last.port": 0,
"gateway.last.tls": false,
"gateway.last.stableID": "manual|invalid|0",
]) {
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == nil)
}
}
private static func makeGatewayConnectConfig(
url: URL = URL(string: "wss://gateway.example.com")!,
stableID: String = "manual|gateway.example.com|443") -> GatewayConnectConfig
{
GatewayConnectConfig(
url: url,
stableID: stableID,
tls: GatewayTLSParams(
required: true,
expectedFingerprint: "abc",
allowTOFU: false,
storeKey: stableID),
token: "token",
bootstrapToken: nil,
password: nil,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["screen", "canvas"],
commands: ["notify", "location.get"],
permissions: ["screen": true],
clientId: "ios",
clientMode: "node",
clientDisplayName: "Phone"))
}
}

View File

@@ -0,0 +1,33 @@
import Testing
@testable import OpenClaw
@Suite(.serialized) struct GatewayConnectionIssueTests {
@Test func detectsTokenMissing() {
let issue = GatewayConnectionIssue.detect(from: "unauthorized: gateway token missing")
#expect(issue == .tokenMissing)
#expect(issue.needsAuthToken)
}
@Test func detectsUnauthorized() {
let issue = GatewayConnectionIssue.detect(from: "Gateway error: unauthorized role")
#expect(issue == .unauthorized)
#expect(issue.needsAuthToken)
}
@Test func detectsPairingWithRequestId() {
let issue = GatewayConnectionIssue.detect(from: "pairing required (requestId: abc123)")
#expect(issue == .pairingRequired(requestId: "abc123"))
#expect(issue.needsPairing)
#expect(issue.requestId == "abc123")
}
@Test func detectsNetworkError() {
let issue = GatewayConnectionIssue.detect(from: "Gateway error: Connection refused")
#expect(issue == .network)
}
@Test func returnsNoneForBenignStatus() {
let issue = GatewayConnectionIssue.detect(from: "Connected")
#expect(issue == .none)
}
}

View File

@@ -0,0 +1,324 @@
import Foundation
import Network
import OpenClawKit
import Testing
import os
@testable import OpenClaw
@Suite(.serialized) struct GatewayConnectionSecurityTests {
@MainActor
private func makeController() -> GatewayConnectionController {
GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
}
private func makeDiscoveredGateway(
stableID: String,
lanHost: String?,
tailnetDns: String?,
gatewayPort: Int?,
fingerprint: String?) -> GatewayDiscoveryModel.DiscoveredGateway
{
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
return GatewayDiscoveryModel.DiscoveredGateway(
name: "Test",
endpoint: endpoint,
stableID: stableID,
debugID: "debug",
lanHost: lanHost,
tailnetDns: tailnetDns,
gatewayPort: gatewayPort,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: fingerprint,
cliPath: nil)
}
private func clearTLSFingerprint(stableID: String) {
GatewayTLSStore.clearFingerprint(stableID: stableID)
}
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
let gateway = self.makeDiscoveredGateway(
stableID: stableID,
lanHost: "evil.example.com",
tailnetDns: "evil.example.com",
gatewayPort: 12345,
fingerprint: "22")
let controller = self.makeController()
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
#expect(params?.expectedFingerprint == "11")
#expect(params?.allowTOFU == false)
}
@Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
let gateway = self.makeDiscoveredGateway(
stableID: stableID,
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
fingerprint: "22")
let controller = self.makeController()
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
#expect(params?.expectedFingerprint == nil)
#expect(params?.allowTOFU == false)
}
@Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
let defaults = UserDefaults.standard
defaults.set(true, forKey: "gateway.autoconnect")
defaults.set(false, forKey: "gateway.manual.enabled")
defaults.removeObject(forKey: "gateway.last.host")
defaults.removeObject(forKey: "gateway.last.port")
defaults.removeObject(forKey: "gateway.last.tls")
defaults.removeObject(forKey: "gateway.last.stableID")
defaults.removeObject(forKey: "gateway.last.kind")
defaults.removeObject(forKey: "gateway.preferredStableID")
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
let gateway = self.makeDiscoveredGateway(
stableID: stableID,
lanHost: "test.local",
tailnetDns: nil,
gatewayPort: 18789,
fingerprint: nil)
let controller = self.makeController()
controller._test_setGateways([gateway])
controller._test_triggerAutoConnect()
#expect(controller._test_didAutoConnect() == false)
}
@Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async {
let controller = self.makeController()
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "gateway.ts.net", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "100.64.0.9", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "localhost", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "127.0.0.1", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "::1", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "[::1]", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "::ffff:127.0.0.1", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "0.0.0.0", useTLS: false) == false)
}
@Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async {
let controller = self.makeController()
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "192.168.1.20", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "10.0.0.5", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "172.16.1.5", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "169.254.1.5", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "fd00::1", useTLS: false) == false)
}
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
let controller = self.makeController()
#expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789)
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443)
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net.", port: 0, useTLS: true) == 443)
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789)
}
@Test @MainActor func manualFirstUseTLSProbeShowsTrustPromptAfterFingerprintCapture() async {
let host = "gateway-\(UUID().uuidString).example.com"
let port = 18789
let stableID = "manual|\(host.lowercased())|\(port)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in .fingerprint("abc123") })
await controller.connectManual(host: host, port: port, useTLS: true)
#expect(controller.pendingTrustPrompt?.fingerprintSha256 == "abc123")
#expect(controller.pendingTrustPrompt?.host == host)
#expect(controller.pendingTrustPrompt?.port == port)
#expect(appModel.gatewayStatusText == "Verify gateway TLS fingerprint")
}
@Test @MainActor func manualFirstUseTLSProbeSkipsTLSWhenTCPIsUnreachable() async {
let host = "gateway-\(UUID().uuidString).example.com"
let port = 18789
let stableID = "manual|\(host.lowercased())|\(port)"
let tlsProbeCalls = OSAllocatedUnfairLock(initialState: 0)
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in false },
tlsFingerprintProbe: { _ in
tlsProbeCalls.withLock { $0 += 1 }
return .fingerprint("abc123")
})
await controller.connectManual(host: host, port: port, useTLS: true)
#expect(tlsProbeCalls.withLock { $0 } == 0)
#expect(controller.pendingTrustPrompt == nil)
#expect(appModel.gatewayStatusText == "Can't reach gateway at \(host):\(port). Check Tailscale or LAN.")
}
@Test @MainActor func manualFirstUseTLSProbeReportsHandshakeTimeoutWithoutTrustPrompt() async {
let host = "gateway-\(UUID().uuidString).example.com"
let port = 18789
let stableID = "manual|\(host.lowercased())|\(port)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
let appModel = NodeAppModel()
let staleProblem = GatewayConnectionProblem(
kind: .pairingRequired,
owner: .gateway,
title: "Pairing required",
message: "Approve an old request.",
requestId: "stale-request",
retryable: true,
pauseReconnect: true)
appModel._test_applyOperatorGatewayConnectionProblem(staleProblem)
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in .failure(.tlsHandshakeTimeout) })
await controller.connectManual(host: host, port: port, useTLS: true)
#expect(controller.pendingTrustPrompt == nil)
#expect(appModel.lastGatewayProblem == nil)
#expect(appModel.gatewayStatusText.contains("TLS fingerprint verification timed out"))
#expect(appModel.gatewayStatusText.contains("\(host):\(port)"))
}
@Test @MainActor func discoveredFirstUseTLSProbeFailureClearsStaleTrustPrompt() async {
let staleHost = "stale-\(UUID().uuidString).example.com"
let stalePort = 18789
let staleStableID = "manual|\(staleHost.lowercased())|\(stalePort)"
let discoveredStableID = "discovered|\(UUID().uuidString)"
let discoveredHost = "gateway.tailnet.ts.net"
let discoveredPort = 443
defer {
clearTLSFingerprint(stableID: staleStableID)
clearTLSFingerprint(stableID: discoveredStableID)
}
self.clearTLSFingerprint(stableID: staleStableID)
self.clearTLSFingerprint(stableID: discoveredStableID)
let tlsResults = OSAllocatedUnfairLock(initialState: [
GatewayTLSFingerprintProbeResult.fingerprint("abc123"),
.failure(.tlsHandshakeTimeout),
])
let appModel = NodeAppModel()
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in tlsResults.withLock { $0.removeFirst() } },
serviceEndpointResolver: { _ in (host: discoveredHost, port: discoveredPort) })
await controller.connectManual(host: staleHost, port: stalePort, useTLS: true)
#expect(controller.pendingTrustPrompt?.fingerprintSha256 == "abc123")
let gateway = self.makeDiscoveredGateway(
stableID: discoveredStableID,
lanHost: nil,
tailnetDns: discoveredHost,
gatewayPort: discoveredPort,
fingerprint: nil)
let message = await controller.connectWithDiagnostics(gateway)
#expect(controller.pendingTrustPrompt == nil)
#expect(message?.contains("TLS fingerprint verification timed out") == true)
#expect(message?.contains("\(discoveredHost):\(discoveredPort)") == true)
#expect(appModel.gatewayStatusText == message)
}
@Test @MainActor func clearAllTLSFingerprints_removesStoredPins() async {
let stableID1 = "test|\(UUID().uuidString)"
let stableID2 = "test|\(UUID().uuidString)"
defer { GatewayTLSStore.clearAllFingerprints() }
GatewayTLSStore.saveFingerprint("11", stableID: stableID1)
GatewayTLSStore.saveFingerprint("22", stableID: stableID2)
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == "11")
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == "22")
GatewayTLSStore.clearAllFingerprints()
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == nil)
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == nil)
}
@Test func trustedPinMismatchCanBeRecoveredByReplacingStoredPin() {
let stableID = "test|\(UUID().uuidString)"
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
GatewayTLSStore.saveFingerprint("old", stableID: stableID)
let error = GatewayTLSValidationError(
failure: GatewayTLSValidationFailure(
kind: .pinMismatch,
host: "gateway.tailnet.ts.net",
storeKey: stableID,
expectedFingerprint: "old",
observedFingerprint: "new",
systemTrustOk: true),
context: "connect to gateway")
let problem = GatewayConnectionProblemMapper.map(error: error)
#expect(problem?.kind == .tlsPinMismatch)
#expect(problem?.canTrustRotatedCertificate == true)
#expect(problem?.tlsStoreKey == stableID)
#expect(problem?.tlsExpectedFingerprint == "old")
#expect(problem?.tlsObservedFingerprint == "new")
#expect(GatewayTLSStore.replaceFingerprint(problem?.tlsObservedFingerprint ?? "", stableID: stableID))
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new")
}
@Test func untrustedPinMismatchCannotBeRecoveredInApp() {
let error = GatewayTLSValidationError(
failure: GatewayTLSValidationFailure(
kind: .pinMismatch,
host: "gateway.tailnet.ts.net",
storeKey: "gateway",
expectedFingerprint: "old",
observedFingerprint: "new",
systemTrustOk: false),
context: "connect to gateway")
let problem = GatewayConnectionProblemMapper.map(error: error)
#expect(problem?.kind == .tlsPinMismatch)
#expect(problem?.canTrustRotatedCertificate == false)
}
}

View File

@@ -0,0 +1,22 @@
import Testing
@testable import OpenClaw
@Suite(.serialized) struct GatewayDiscoveryModelTests {
@Test @MainActor func debugLoggingCapturesLifecycleAndResets() {
let model = GatewayDiscoveryModel()
#expect(model.debugLog.isEmpty)
#expect(model.statusText == "Idle")
model.setDebugLoggingEnabled(true)
#expect(model.debugLog.count >= 2)
model.stop()
#expect(model.statusText == "Stopped")
#expect(model.gateways.isEmpty)
#expect(model.debugLog.count >= 3)
model.setDebugLoggingEnabled(false)
#expect(model.debugLog.isEmpty)
}
}

View File

@@ -0,0 +1,33 @@
import OpenClawKit
import Network
import Testing
@testable import OpenClaw
@Suite struct GatewayEndpointIDTests {
@Test func stableIDForServiceDecodesAndNormalizesName() {
let endpoint = NWEndpoint.service(
name: "OpenClaw\\032Gateway \\032 Node\n",
type: "_openclaw-gw._tcp",
domain: "local.",
interface: nil)
#expect(GatewayEndpointID.stableID(endpoint) == "_openclaw-gw._tcp|local.|OpenClaw Gateway Node")
}
@Test func stableIDForNonServiceUsesEndpointDescription() {
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242)
#expect(GatewayEndpointID.stableID(endpoint) == String(describing: endpoint))
}
@Test func prettyDescriptionDecodesBonjourEscapes() {
let endpoint = NWEndpoint.service(
name: "OpenClaw\\032Gateway",
type: "_openclaw-gw._tcp",
domain: "local.",
interface: nil)
let pretty = GatewayEndpointID.prettyDescription(endpoint)
#expect(pretty == BonjourEscapes.decode(String(describing: endpoint)))
#expect(!pretty.localizedCaseInsensitiveContains("\\032"))
}
}

View File

@@ -0,0 +1,54 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
struct GatewayProblemPrimaryActionTests {
@Test func `protocol mismatch uses update action instead of retry`() {
let problem = GatewayConnectionProblem(
kind: .protocolMismatch,
owner: .iphone,
title: "App update required",
message: "This app is older than the gateway.",
actionLabel: "Update app",
retryable: false,
pauseReconnect: true)
let title = GatewayProblemPrimaryAction.title(for: problem, retryTitle: "Retry connection")
#expect(title == "Update app")
}
@Test func `reset-suggesting problem uses reset title when provided`() {
let problem = GatewayConnectionProblem(
kind: .gatewayAuthTokenMismatch,
owner: .iphone,
title: "Stored gateway token rejected",
message: "Reset onboarding to pair again.",
retryable: false,
pauseReconnect: true)
let title = GatewayProblemPrimaryAction.title(
for: problem,
retryTitle: "Retry",
resetTitle: "Reset onboarding",
nonRetryableTitle: "Open Settings")
#expect(title == "Reset onboarding")
}
@Test func `retryable problem uses mapped action label`() {
let problem = GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "Check the gateway network path.",
actionLabel: "Try again",
retryable: true,
pauseReconnect: false)
let title = GatewayProblemPrimaryAction.title(for: problem, retryTitle: "Retry connection")
#expect(title == "Try again")
}
}

View File

@@ -0,0 +1,188 @@
import Foundation
import Testing
@testable import OpenClaw
private struct KeychainEntry: Hashable {
let service: String
let account: String
}
private let gatewayService = "ai.openclawfoundation.app.gateway"
private let nodeService = "ai.openclawfoundation.app.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
private let bootstrapDefaultsKeys = [
"node.instanceId",
"gateway.preferredStableID",
"gateway.lastDiscoveredStableID",
]
private let bootstrapKeychainEntries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
private let lastGatewayDefaultsKeys = [
"gateway.last.kind",
"gateway.last.host",
"gateway.last.port",
"gateway.last.tls",
"gateway.last.stableID",
]
private let lastGatewayKeychainEntry = KeychainEntry(service: gatewayService, account: "lastConnection")
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in keys {
snapshot[key] = defaults.object(forKey: key)
}
return snapshot
}
private func applyDefaults(_ values: [String: Any?]) {
let defaults = UserDefaults.standard
for (key, value) in values {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
private func restoreDefaults(_ snapshot: [String: Any?]) {
applyDefaults(snapshot)
}
private func snapshotKeychain(_ entries: [KeychainEntry]) -> [KeychainEntry: String?] {
var snapshot: [KeychainEntry: String?] = [:]
for entry in entries {
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
}
return snapshot
}
private func applyKeychain(_ values: [KeychainEntry: String?]) {
for (entry, value) in values {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
}
private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyKeychain(snapshot)
}
private func withBootstrapSnapshots(_ body: () -> Void) {
let defaultsSnapshot = snapshotDefaults(bootstrapDefaultsKeys)
let keychainSnapshot = snapshotKeychain(bootstrapKeychainEntries)
defer {
restoreDefaults(defaultsSnapshot)
restoreKeychain(keychainSnapshot)
}
body()
}
private func withLastGatewaySnapshot(_ body: () -> Void) {
let defaultsSnapshot = snapshotDefaults(lastGatewayDefaultsKeys)
let keychainSnapshot = snapshotKeychain([lastGatewayKeychainEntry])
defer {
restoreDefaults(defaultsSnapshot)
restoreKeychain(keychainSnapshot)
}
body()
}
@Suite(.serialized) struct GatewaySettingsStoreTests {
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
withBootstrapSnapshots {
applyDefaults([
"node.instanceId": "node-test",
"gateway.preferredStableID": "preferred-test",
"gateway.lastDiscoveredStableID": "last-test",
])
applyKeychain([
instanceIdEntry: nil,
preferredGatewayEntry: nil,
lastGatewayEntry: nil,
])
GatewaySettingsStore.bootstrapPersistence()
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
}
}
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
withBootstrapSnapshots {
applyDefaults([
"node.instanceId": nil,
"gateway.preferredStableID": nil,
"gateway.lastDiscoveredStableID": nil,
])
applyKeychain([
instanceIdEntry: "node-from-keychain",
preferredGatewayEntry: "preferred-from-keychain",
lastGatewayEntry: "last-from-keychain",
])
GatewaySettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
}
}
@Test func lastGateway_manualRoundTrip() {
withLastGatewaySnapshot {
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "example.com",
port: 443,
useTLS: true,
stableID: "manual|example.com|443")
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
}
}
@Test func lastGateway_discoveredOverwritesManual() {
withLastGatewaySnapshot {
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "10.0.0.99",
port: 18789,
useTLS: true,
stableID: "manual|10.0.0.99|18789")
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
}
}
@Test func lastGateway_migratesFromUserDefaults() {
withLastGatewaySnapshot {
// Clear Keychain entry and plant legacy UserDefaults values.
applyKeychain([lastGatewayKeychainEntry: nil])
applyDefaults([
"gateway.last.kind": nil,
"gateway.last.host": "example.org",
"gateway.last.port": 18789,
"gateway.last.tls": false,
"gateway.last.stableID": "manual|example.org|18789",
])
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
// Legacy keys should be cleaned up after migration.
let defaults = UserDefaults.standard
#expect(defaults.object(forKey: "gateway.last.stableID") == nil)
#expect(defaults.object(forKey: "gateway.last.host") == nil)
}
}
}

View File

@@ -0,0 +1,56 @@
import OpenClawKit
import Testing
@testable import OpenClaw
@Suite struct GatewayStatusBuilderTests {
@Test func pausedProblemKeepsErrorStatus() {
let state = GatewayStatusBuilder.build(
gatewayServerName: nil,
lastGatewayProblem: GatewayConnectionProblem(
kind: .pairingRequired,
owner: .gateway,
title: "Pairing required",
message: "Approve this device before reconnecting.",
requestId: "req-123",
retryable: false,
pauseReconnect: true),
gatewayStatusText: "Reconnecting…")
#expect(state == .error)
}
@Test func transientProblemAllowsConnectingStatus() {
let state = GatewayStatusBuilder.build(
gatewayServerName: nil,
lastGatewayProblem: GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
retryable: true,
pauseReconnect: false),
gatewayStatusText: "Reconnecting…")
#expect(state == .connecting)
}
@Test func chatGatewayPillLabelsMatchDisplayState() {
#expect(ChatProTab.gatewayPillTitle(state: .disconnected, isGatewayUsable: false) == "Offline")
#expect(ChatProTab.gatewayPillTitle(state: .connecting, isGatewayUsable: false) == "Connecting")
#expect(ChatProTab.gatewayPillTitle(state: .error, isGatewayUsable: false) == "Attention")
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: true) == "Connected")
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: false) == "Unavailable")
}
@Test func `chat agent badge rejects placeholder question mark`() {
#expect(ChatProTab.normalizedBadgeEmoji(" 🦞 ") == "🦞")
#expect(ChatProTab.normalizedBadgeEmoji("?") == nil)
#expect(ChatProTab.normalizedBadgeEmoji(" ") == nil)
#expect(ChatProTab.normalizedBadgeEmoji(nil) == nil)
}
@Test func `chat starter prompts stay stable and actionable`() {
#expect(ChatProTab.emptyAssistantPrompts.map(\.id) == ["summarize-status", "show-controls", "start-voice"])
#expect(ChatProTab.emptyAssistantPrompts.allSatisfy { !$0.title.isEmpty && !$0.prompt.isEmpty })
}
}

View File

@@ -0,0 +1,202 @@
import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import Testing
@testable import OpenClaw
@Suite struct IOSGatewayChatTransportTests {
private func object(from json: String) throws -> [String: Any] {
let data = try #require(json.data(using: .utf8))
let value = try JSONSerialization.jsonObject(with: data)
return try #require(value as? [String: Any])
}
@Test func agentWaitTreatsSuccessAsCompletion() {
#expect(IOSGatewayChatTransport.isAgentWaitCompletionStatus("success"))
#expect(IOSGatewayChatTransport.isAgentWaitCompletionStatus(" ok "))
#expect(IOSGatewayChatTransport.isAgentWaitCompletionStatus("completed"))
#expect(IOSGatewayChatTransport.isAgentWaitCompletionStatus("succeeded"))
#expect(!IOSGatewayChatTransport.isAgentWaitCompletionStatus("timeout"))
#expect(!IOSGatewayChatTransport.isAgentWaitCompletionStatus("failed"))
}
@Test func agentWaitTimeoutAddsGatewayMargin() {
#expect(IOSGatewayChatTransport.agentWaitRequestTimeoutSeconds(timeoutMs: 1) == 6)
#expect(IOSGatewayChatTransport.agentWaitRequestTimeoutSeconds(timeoutMs: 1000) == 6)
#expect(IOSGatewayChatTransport.agentWaitRequestTimeoutSeconds(timeoutMs: 30000) == 35)
}
@Test func compactionLeavesTerminalTimeoutToGateway() {
#expect(IOSGatewayChatTransport.compactionRequestTimeoutSeconds == 0)
}
@Test func agentWaitCompletionDecodesFallbackRunId() throws {
let data = Data(#"{"status":"completed"}"#.utf8)
let completion = try IOSGatewayChatTransport.decodeAgentWaitCompletion(data, fallbackRunId: "run-local")
#expect(completion.runId == "run-local")
#expect(completion.status == "completed")
#expect(completion.completed)
}
@Test func listSessionsParamsIncludeGlobalSessionsButNotUnknown() throws {
let params = try self.object(from: IOSGatewayChatTransport.makeListSessionsParamsJSON(limit: 12))
#expect(params["includeGlobal"] as? Bool == true)
#expect(params["includeUnknown"] as? Bool == false)
#expect(params["limit"] as? Int == 12)
}
@Test func commandsListParamsRequestTextScopeWithArgs() throws {
let params = try self.object(from: IOSGatewayChatTransport.makeCommandsListParamsJSON())
#expect(params["scope"] as? String == "text")
#expect(params["includeArgs"] as? Bool == true)
#expect(params["agentId"] == nil)
}
@Test func commandsListParamsIncludeAgentForAgentScopedSession() throws {
let params = try self.object(
from: IOSGatewayChatTransport.makeCommandsListParamsJSON(sessionKey: "agent:reviewer:ios-main"))
#expect(params["scope"] as? String == "text")
#expect(params["includeArgs"] as? Bool == true)
#expect(params["agentId"] as? String == "reviewer")
}
@Test func chatSendParamsOmitEmptyAttachmentsAndKeepSessionFields() throws {
let params = try self.object(
from: IOSGatewayChatTransport.makeChatSendParamsJSON(
sessionKey: "agent:main",
message: "hello",
thinking: "low",
idempotencyKey: "send-1",
attachments: []))
#expect(params["sessionKey"] as? String == "agent:main")
#expect(params["message"] as? String == "hello")
#expect(params["thinking"] as? String == "low")
#expect(params["idempotencyKey"] as? String == "send-1")
#expect(params["timeoutMs"] as? Int == IOSGatewayChatTransport.defaultChatSendTimeoutMs)
#expect(params["attachments"] == nil)
}
@Test func requestsFailFastWhenGatewayNotConnected() async {
let gateway = GatewayNodeSession()
let transport = IOSGatewayChatTransport(gateway: gateway)
do {
_ = try await transport.requestHistory(sessionKey: "node-test")
Issue.record("Expected requestHistory to throw when gateway not connected")
} catch {}
do {
_ = try await transport.sendMessage(
sessionKey: "node-test",
message: "hello",
thinking: "low",
idempotencyKey: "idempotency",
attachments: [])
Issue.record("Expected sendMessage to throw when gateway not connected")
} catch {}
do {
_ = try await transport.requestHealth(timeoutMs: 250)
Issue.record("Expected requestHealth to throw when gateway not connected")
} catch {}
do {
try await transport.resetSession(sessionKey: "node-test")
Issue.record("Expected resetSession to throw when gateway not connected")
} catch {}
do {
try await transport.setActiveSessionKey("node-test")
Issue.record("Expected setActiveSessionKey to throw when gateway not connected")
} catch {}
}
@Test func mapsSessionMessageEventToSessionMessage() {
let payload = AnyCodable([
"sessionKey": AnyCodable("agent:main:main"),
"agentId": AnyCodable("main"),
"messageId": AnyCodable("msg-1"),
"messageSeq": AnyCodable(7),
"message": AnyCodable([
"role": AnyCodable("assistant"),
"content": AnyCodable([
AnyCodable([
"type": AnyCodable("text"),
"text": AnyCodable("agent reply"),
]),
]),
"timestamp": AnyCodable(1234.5),
]),
])
let frame = EventFrame(
type: "event",
event: "session.message",
payload: payload,
seq: 1,
stateversion: nil)
let mapped = IOSGatewayChatTransport.mapEventFrame(frame)
switch mapped {
case let .sessionMessage(message):
#expect(message.sessionKey == "agent:main:main")
#expect(message.agentId == "main")
#expect(message.messageId == "msg-1")
#expect(message.messageSeq == 7)
#expect(message.message?.role == "assistant")
#expect(message.message?.content.first?.text == "agent reply")
default:
Issue.record("expected .sessionMessage from session.message event, got \(String(describing: mapped))")
}
}
@Test func mapsChatEventToChat() {
let payload = AnyCodable([
"runId": AnyCodable("run-1"),
"sessionKey": AnyCodable("main"),
"state": AnyCodable("final"),
])
let frame = EventFrame(type: "event", event: "chat", payload: payload, seq: 1, stateversion: nil)
let mapped = IOSGatewayChatTransport.mapEventFrame(frame)
switch mapped {
case let .chat(chat):
#expect(chat.runId == "run-1")
#expect(chat.sessionKey == "main")
#expect(chat.state == "final")
default:
Issue.record("expected .chat from chat event, got \(String(describing: mapped))")
}
}
@Test func mapsUnknownEventToNil() {
let frame = EventFrame(
type: "event",
event: "unknown",
payload: AnyCodable(["a": AnyCodable(1)]),
seq: 1,
stateversion: nil)
let mapped = IOSGatewayChatTransport.mapEventFrame(frame)
#expect(mapped == nil)
}
}
@Suite struct LocalFixtureChatTransportTests {
@Test func sentUserTurnCarriesGatewayIdempotencyMetadata() async throws {
let transport = LocalFixtureChatTransport(fixture: .appleReviewDemo)
_ = try await transport.sendMessage(
sessionKey: "main",
message: "hello",
thinking: "auto",
idempotencyKey: "fixture-run",
attachments: [])
let history = try await transport.requestHistory(sessionKey: "main")
let decoded = try #require(history.messages).compactMap { payload -> OpenClawChatMessage? in
guard let data = try? JSONEncoder().encode(payload) else { return nil }
return try? JSONDecoder().decode(OpenClawChatMessage.self, from: data)
}
#expect(decoded.last(where: { $0.role == "user" })?.idempotencyKey == "fixture-run:user")
}
}

24
apps/ios/Tests/Info.plist Normal file
View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OpenClawTests</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
</dict>
</plist>

View File

@@ -0,0 +1,22 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite struct KeychainStoreTests {
@Test func saveLoadUpdateDeleteRoundTrip() {
let service = "ai.openclawfoundation.app.tests.\(UUID().uuidString)"
let account = "value"
#expect(KeychainStore.delete(service: service, account: account))
#expect(KeychainStore.loadString(service: service, account: account) == nil)
#expect(KeychainStore.saveString("first", service: service, account: account))
#expect(KeychainStore.loadString(service: service, account: account) == "first")
#expect(KeychainStore.saveString("second", service: service, account: account))
#expect(KeychainStore.loadString(service: service, account: account) == "second")
#expect(KeychainStore.delete(service: service, account: account))
#expect(KeychainStore.loadString(service: service, account: account) == nil)
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
import Testing
@testable import OpenClaw
struct LicenseDocumentLoaderTests {
@Test func `loads only utf8 text licenses sorted alphabetically by title`() throws {
let directory = try Self.makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
try "Gamma License".write(
to: directory.appendingPathComponent("Gamma.txt"),
atomically: true,
encoding: .utf8)
try "Alpha License".write(
to: directory.appendingPathComponent("Alpha.txt"),
atomically: true,
encoding: .utf8)
try "Ignored".write(
to: directory.appendingPathComponent("Beta.md"),
atomically: true,
encoding: .utf8)
try "Hidden".write(
to: directory.appendingPathComponent(".Hidden.txt"),
atomically: true,
encoding: .utf8)
let documents = LicenseDocumentLoader.documents(in: directory)
#expect(documents.map(\.filename) == ["Alpha.txt", "Gamma.txt"])
#expect(documents.map(\.title) == ["Alpha", "Gamma"])
#expect(documents.map(\.body) == ["Alpha License", "Gamma License"])
}
@Test func `derives readable titles from license filenames`() {
#expect(LicenseDocumentLoader.title(from: "WebRTC.txt") == "WebRTC")
#expect(LicenseDocumentLoader.title(from: "SwiftUI Siri Waveform.txt") == "SwiftUI Siri Waveform")
#expect(LicenseDocumentLoader.title(from: "openclaw_plugin_sdk.txt") == "openclaw plugin sdk")
#expect(LicenseDocumentLoader.title(from: "010-WebRTC.txt") == "010 WebRTC")
}
@Test func `bundles the waveform attribution`() {
let waveform = LicenseDocumentLoader.bundledDocuments()
.first { $0.filename == "SwiftUI Siri Waveform.txt" }
#expect(waveform?.body.contains("Copyright (c) 2019 Noah Chalifour") == true)
#expect(waveform?.body.contains("MIT License") == true)
}
private static func makeTemporaryDirectory() throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("OpenClawLicenseDocumentLoaderTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}
}

View File

@@ -0,0 +1,223 @@
import CoreLocation
import Testing
@testable import OpenClaw
@testable import OpenClawKit
@Suite(.serialized) struct LocationPermissionSummaryTests {
@Test func `always desired when in use authorized needs attention`() {
let summary = LocationPermissionSummary(
desiredMode: .always,
locationServicesEnabled: true,
authorizationStatus: .authorizedWhenInUse,
accuracyAuthorization: .fullAccuracy)
#expect(summary.effectiveMode == .whileUsing)
#expect(!summary.canUseLocationInBackground)
#expect(summary.needsAttention)
#expect(summary.statusText == "While Using")
#expect(summary.detailText.contains("Always is selected"))
}
@Test func `always desired authorized always allows background`() {
let summary = LocationPermissionSummary(
desiredMode: .always,
locationServicesEnabled: true,
authorizationStatus: .authorizedAlways,
accuracyAuthorization: .reducedAccuracy)
#expect(summary.effectiveMode == .always)
#expect(summary.canUseLocationInBackground)
#expect(!summary.needsAttention)
#expect(summary.detailText.contains("Background location requests"))
#expect(summary.detailText.contains("Precise Location is off"))
}
@Test func `off desired ignores granted permission`() {
let summary = LocationPermissionSummary(
desiredMode: .off,
locationServicesEnabled: false,
authorizationStatus: .authorizedAlways,
accuracyAuthorization: .fullAccuracy)
#expect(summary.effectiveMode == .off)
#expect(!summary.canUseLocationInBackground)
#expect(!summary.needsAttention)
#expect(summary.detailText.contains("Location sharing is disabled"))
#expect(summary.detailText.contains("Location Services are off"))
}
@Test func `off desired still reports ios always grant`() {
let summary = LocationPermissionSummary(
desiredMode: .off,
locationServicesEnabled: true,
authorizationStatus: .authorizedAlways,
accuracyAuthorization: .fullAccuracy)
#expect(summary.effectiveMode == .off)
#expect(!summary.canUseLocationInBackground)
#expect(!summary.needsAttention)
#expect(summary.detailText.contains("Location sharing is disabled"))
#expect(summary.detailText.contains("Always"))
}
@Test func `off desired still reports ios while using grant`() {
let summary = LocationPermissionSummary(
desiredMode: .off,
locationServicesEnabled: true,
authorizationStatus: .authorizedWhenInUse,
accuracyAuthorization: .fullAccuracy)
#expect(summary.effectiveMode == .off)
#expect(!summary.canUseLocationInBackground)
#expect(!summary.needsAttention)
#expect(summary.detailText.contains("Location sharing is disabled"))
#expect(summary.detailText.contains("While Using"))
}
@Test func `disabled location services override app grant`() {
let summary = LocationPermissionSummary(
desiredMode: .always,
locationServicesEnabled: false,
authorizationStatus: .authorizedAlways,
accuracyAuthorization: .fullAccuracy)
#expect(summary.effectiveMode == .off)
#expect(!summary.canUseLocationInBackground)
#expect(summary.needsAttention)
#expect(summary.statusText == "Off")
#expect(summary.detailText == "Location Services are off in iOS Settings.")
}
@MainActor @Test func `off mode stops significant location monitoring`() async {
let locationService = MockLocationService(authorizationStatus: .authorizedAlways)
let appModel = NodeAppModel(locationService: locationService)
let granted = await appModel.requestLocationPermissions(mode: .off)
#expect(granted)
#expect(locationService.backgroundUpdatesEnabled == false)
#expect(locationService.stopMonitoringCallCount == 1)
}
@MainActor @Test func `while using mode stops significant location monitoring when always remains granted`() async {
let locationService = MockLocationService(authorizationStatus: .authorizedAlways)
let appModel = NodeAppModel(locationService: locationService)
let granted = await appModel.requestLocationPermissions(mode: .whileUsing)
#expect(granted)
#expect(locationService.backgroundUpdatesEnabled == false)
#expect(locationService.stopMonitoringCallCount == 1)
}
@MainActor @Test func `always mode starts significant location monitoring when always is granted`() async {
let locationService = MockLocationService(authorizationStatus: .authorizedAlways)
let appModel = NodeAppModel(locationService: locationService)
let granted = await appModel.requestLocationPermissions(mode: .always)
#expect(granted)
#expect(locationService.backgroundUpdatesEnabled == true)
#expect(locationService.stopMonitoringCallCount == 0)
#expect(locationService.startMonitoringCallCount == 1)
}
@MainActor @Test func `always mode remains selected when ios only grants while using`() async {
let locationService = MockLocationService(authorizationStatus: .authorizedWhenInUse)
let appModel = NodeAppModel(locationService: locationService)
let granted = await appModel.requestLocationPermissions(mode: .always)
#expect(granted)
#expect(locationService.backgroundUpdatesEnabled == false)
#expect(locationService.stopMonitoringCallCount == 1)
}
@MainActor @Test func `external downgrade and always restoration reconcile significant monitoring`() {
let defaultsKey = "location.enabledMode"
let previous = UserDefaults.standard.object(forKey: defaultsKey)
defer {
if let previous {
UserDefaults.standard.set(previous, forKey: defaultsKey)
} else {
UserDefaults.standard.removeObject(forKey: defaultsKey)
}
}
UserDefaults.standard.set(OpenClawLocationMode.always.rawValue, forKey: defaultsKey)
let locationService = MockLocationService(authorizationStatus: .authorizedAlways)
let appModel = NodeAppModel(locationService: locationService)
withExtendedLifetime(appModel) {
locationService.simulateAuthorizationChange(.authorizedAlways)
locationService.simulateAuthorizationChange(.authorizedWhenInUse)
locationService.simulateAuthorizationChange(.authorizedAlways)
}
#expect(locationService.backgroundUpdatesEnabled == true)
#expect(locationService.startMonitoringCallCount == 2)
#expect(locationService.stopMonitoringCallCount == 1)
}
}
@MainActor
private final class MockLocationService: LocationServicing, @unchecked Sendable {
private var status: CLAuthorizationStatus
private var authorizationChangeHandler: (@MainActor @Sendable (CLAuthorizationStatus) -> Void)?
var backgroundUpdatesEnabled: Bool?
var startMonitoringCallCount = 0
var stopMonitoringCallCount = 0
init(authorizationStatus: CLAuthorizationStatus) {
self.status = authorizationStatus
}
func authorizationStatus() -> CLAuthorizationStatus {
self.status
}
func accuracyAuthorization() -> CLAccuracyAuthorization {
.fullAccuracy
}
func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus {
_ = mode
return self.status
}
func currentLocation(
params: OpenClawLocationGetParams,
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
_ = params
_ = desiredAccuracy
_ = maxAgeMs
_ = timeoutMs
throw LocationService.Error.unavailable
}
func setBackgroundLocationUpdatesEnabled(_ enabled: Bool) {
self.backgroundUpdatesEnabled = enabled
}
func setAuthorizationChangeHandler(
_ handler: @escaping @MainActor @Sendable (CLAuthorizationStatus) -> Void)
{
self.authorizationChangeHandler = handler
}
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) {
_ = onUpdate
self.startMonitoringCallCount += 1
}
func stopMonitoringSignificantLocationChanges() {
self.stopMonitoringCallCount += 1
}
func simulateAuthorizationChange(_ status: CLAuthorizationStatus) {
self.status = status
self.authorizationChangeHandler?(status)
}
}

View File

@@ -0,0 +1,85 @@
import Foundation
import OpenClawKit
import Testing
private let iOSSilenceTimeoutMs = 900
@Suite struct TalkConfigParsingTests {
@Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() {
let talk: [String: Any] = [
"provider": "elevenlabs",
"providers": [
"elevenlabs": [
"voiceId": "voice-normalized",
],
],
"voiceId": "voice-legacy",
]
let selection = TalkConfigParsing.selectProviderConfig(
TalkConfigParsing.bridgeFoundationDictionary(talk),
defaultProvider: "elevenlabs",
allowLegacyFallback: false)
#expect(selection == nil)
}
@Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() {
let talk: [String: Any] = [
"voiceId": "voice-legacy",
"apiKey": "legacy-key", // pragma: allowlist secret
]
let selection = TalkConfigParsing.selectProviderConfig(
TalkConfigParsing.bridgeFoundationDictionary(talk),
defaultProvider: "elevenlabs",
allowLegacyFallback: false)
#expect(selection == nil)
}
@Test func readsConfiguredSilenceTimeoutMs() {
let talk: [String: Any] = [
"silenceTimeoutMs": 1500,
]
#expect(
TalkConfigParsing.resolvedSilenceTimeoutMs(
TalkConfigParsing.bridgeFoundationDictionary(talk),
fallback: iOSSilenceTimeoutMs) == 1500)
}
@Test func readsConfiguredSpeechLocale() {
let talk: [String: Any] = [
"speechLocale": " ru-RU ",
]
#expect(
TalkConfigParsing.resolvedSpeechLocaleID(
TalkConfigParsing.bridgeFoundationDictionary(talk)) == "ru-RU")
}
@Test func defaultsSilenceTimeoutMsWhenMissing() {
#expect(TalkConfigParsing.resolvedSilenceTimeoutMs(nil, fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
}
@Test func defaultsSilenceTimeoutMsWhenInvalid() {
let talk: [String: Any] = [
"silenceTimeoutMs": 0,
]
#expect(
TalkConfigParsing.resolvedSilenceTimeoutMs(
TalkConfigParsing.bridgeFoundationDictionary(talk),
fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
}
@Test func defaultsSilenceTimeoutMsWhenBool() {
let talk: [String: Any] = [
"silenceTimeoutMs": true,
]
#expect(
TalkConfigParsing.resolvedSilenceTimeoutMs(
TalkConfigParsing.bridgeFoundationDictionary(talk),
fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
import Contacts
import EventKit
import OpenClawKit
import Testing
@testable import OpenClaw
@Suite struct NodeServicePermissionTests {
@Test func `calendar events do not request access from node invoke`() async throws {
let service = CalendarService(eventAuthorizationStatus: { .notDetermined })
await expectPermissionError(
code: "CALENDAR_PERMISSION_REQUIRED",
performing: {
_ = try await service.events(params: OpenClawCalendarEventsParams())
})
}
@Test func `calendar add does not request access from node invoke`() async throws {
let service = CalendarService(eventAuthorizationStatus: { .notDetermined })
await expectPermissionError(
code: "CALENDAR_PERMISSION_REQUIRED",
performing: {
_ = try await service.add(params: calendarAddParams())
})
}
@Test func `reminders list does not request access from node invoke`() async throws {
let service = RemindersService(reminderAuthorizationStatus: { .notDetermined })
await expectPermissionError(
code: "REMINDERS_PERMISSION_REQUIRED",
performing: {
_ = try await service.list(params: OpenClawRemindersListParams())
})
}
@Test func `reminders add does not request access from node invoke`() async throws {
let service = RemindersService(reminderAuthorizationStatus: { .notDetermined })
await expectPermissionError(
code: "REMINDERS_PERMISSION_REQUIRED",
performing: {
_ = try await service.add(params: OpenClawRemindersAddParams(title: "Follow up"))
})
}
@Test func `contacts search does not request access from node invoke`() async throws {
let service = ContactsService(authorizationStatus: { .notDetermined })
await expectPermissionError(
code: "CONTACTS_PERMISSION_REQUIRED",
performing: {
_ = try await service.search(params: OpenClawContactsSearchParams(query: "Ada"))
})
}
@Test func `contacts add does not request access from node invoke`() async throws {
let service = ContactsService(authorizationStatus: { .notDetermined })
await expectPermissionError(
code: "CONTACTS_PERMISSION_REQUIRED",
performing: {
_ = try await service.add(params: OpenClawContactsAddParams(givenName: "Ada"))
})
}
}
private func calendarAddParams() -> OpenClawCalendarAddParams {
OpenClawCalendarAddParams(
title: "Review",
startISO: "2026-07-03T09:00:00Z",
endISO: "2026-07-03T09:30:00Z")
}
private func expectPermissionError(
code: String,
performing operation: () async throws -> Void) async
{
do {
try await operation()
Issue.record("Expected \(code)")
} catch {
#expect(error.localizedDescription.contains(code))
}
}

View File

@@ -0,0 +1,108 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite(.serialized) struct OnboardingStateStoreTests {
@Test @MainActor func shouldPresentWhenFreshAndDisconnected() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
let appModel = NodeAppModel()
appModel.gatewayServerName = nil
#expect(OnboardingStateStore.shouldPresentOnLaunch(
appModel: appModel,
defaults: defaults,
hasSavedGatewayConnection: false))
}
@Test @MainActor func doesNotPresentWhenConnected() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
let appModel = NodeAppModel()
appModel.gatewayServerName = "gateway"
#expect(!OnboardingStateStore.shouldPresentOnLaunch(
appModel: appModel,
defaults: defaults,
hasSavedGatewayConnection: false))
}
@Test @MainActor func doesNotPresentForSavedGatewayBeforeReconnectCompletes() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
let appModel = NodeAppModel()
appModel.gatewayServerName = nil
#expect(!OnboardingStateStore.shouldPresentOnLaunch(
appModel: appModel,
defaults: defaults,
hasSavedGatewayConnection: true))
}
@Test @MainActor func markCompletedPersistsMode() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
let appModel = NodeAppModel()
appModel.gatewayServerName = nil
OnboardingStateStore.markCompleted(mode: .remoteDomain, defaults: defaults)
#expect(OnboardingStateStore.lastMode(defaults: defaults) == .remoteDomain)
#expect(!OnboardingStateStore.shouldPresentOnLaunch(
appModel: appModel,
defaults: defaults,
hasSavedGatewayConnection: false))
}
@Test func firstRunIntroDefaultsToVisibleThenPersists() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
#expect(OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
OnboardingStateStore.markFirstRunIntroSeen(defaults: defaults)
#expect(!OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
}
@Test @MainActor func resetClearsCompletionAndIntroSeen() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
OnboardingStateStore.markCompleted(mode: .homeNetwork, defaults: defaults)
OnboardingStateStore.markFirstRunIntroSeen(defaults: defaults)
OnboardingStateStore.reset(defaults: defaults)
let appModel = NodeAppModel()
appModel.gatewayServerName = nil
#expect(OnboardingStateStore.shouldPresentOnLaunch(
appModel: appModel,
defaults: defaults,
hasSavedGatewayConnection: false))
#expect(OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
#expect(OnboardingStateStore.lastMode(defaults: defaults) == .homeNetwork)
}
private struct TestDefaults {
var suiteName: String
var defaults: UserDefaults
}
private func makeDefaults() -> TestDefaults {
let suiteName = "OnboardingStateStoreTests.\(UUID().uuidString)"
return TestDefaults(
suiteName: suiteName,
defaults: UserDefaults(suiteName: suiteName) ?? .standard)
}
private func reset(_ defaults: TestDefaults) {
defaults.defaults.removePersistentDomain(forName: defaults.suiteName)
}
}

View File

@@ -0,0 +1,34 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite(.serialized) struct OpenClawAppDelegateTests {
@Test @MainActor func `resolves registry model before view task assigns delegate model`() {
let registryModel = NodeAppModel()
OpenClawAppModelRegistry.appModel = registryModel
defer { OpenClawAppModelRegistry.appModel = nil }
let delegate = OpenClawAppDelegate()
#expect(delegate._test_resolvedAppModel() === registryModel)
}
@Test @MainActor func `prefers explicit delegate model over registry fallback`() {
let registryModel = NodeAppModel()
let explicitModel = NodeAppModel()
OpenClawAppModelRegistry.appModel = registryModel
defer { OpenClawAppModelRegistry.appModel = nil }
let delegate = OpenClawAppDelegate()
delegate.appModel = explicitModel
#expect(delegate._test_resolvedAppModel() === explicitModel)
}
@Test @MainActor func `derives background refresh task identifier from app bundle identifier`() {
let delegate = OpenClawAppDelegate()
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "ai.openclawfoundation.app.tests"
#expect(delegate._test_wakeRefreshTaskIdentifier() == "\(bundleIdentifier).bgrefresh")
}
}

View File

@@ -0,0 +1,100 @@
import Testing
import UIKit
@testable import OpenClaw
struct OpenClawBrandTests {
@Test func `brand colors meet text contrast in both appearances`() {
let foregroundColors = [
("accent", OpenClawBrand.uiAccentForeground),
("accentHot", OpenClawBrand.uiAccentHotForeground),
("ok", OpenClawBrand.uiOK),
("warn", OpenClawBrand.uiWarn),
("danger", OpenClawBrand.uiDanger),
("info", OpenClawBrand.uiInfo),
]
let backgrounds = [UIColor.systemBackground, UIColor.secondarySystemBackground]
for style in [UIUserInterfaceStyle.light, .dark] {
let traits = UITraitCollection(userInterfaceStyle: style)
for (name, color) in foregroundColors {
for background in backgrounds {
#expect(
Self.contrastRatio(color, background, traits: traits) >= 4.5,
"\(name) on system background in \(style)")
}
let tintedBackground = Self.composite(
color,
alpha: 0.10,
over: .secondarySystemGroupedBackground,
traits: traits)
#expect(
Self.contrastRatio(color, tintedBackground, traits: traits) >= 4.5,
"\(name) on tinted background in \(style)")
}
let pillBackground = Self.composite(
OpenClawBrand.uiAccentForeground,
alpha: style == .dark ? 0.12 : 0.08,
over: .secondarySystemGroupedBackground,
traits: traits)
#expect(Self.contrastRatio(OpenClawBrand.uiAccentForeground, pillBackground, traits: traits) >= 4.5)
#expect(Self.contrastRatio(OpenClawBrand.uiAccent, .white, traits: traits) >= 4.5)
}
}
private static func composite(
_ foreground: UIColor,
alpha: CGFloat,
over background: UIColor,
traits: UITraitCollection) -> UIColor
{
let foregroundComponents = Self.rgbComponents(foreground, traits: traits)
let backgroundComponents = Self.rgbComponents(background, traits: traits)
return UIColor(
red: foregroundComponents.red * alpha + backgroundComponents.red * (1 - alpha),
green: foregroundComponents.green * alpha + backgroundComponents.green * (1 - alpha),
blue: foregroundComponents.blue * alpha + backgroundComponents.blue * (1 - alpha),
alpha: 1)
}
private static func contrastRatio(
_ foreground: UIColor,
_ background: UIColor,
traits: UITraitCollection) -> CGFloat
{
let foregroundLuminance = Self.relativeLuminance(foreground, traits: traits)
let backgroundLuminance = Self.relativeLuminance(background, traits: traits)
let lighter = max(foregroundLuminance, backgroundLuminance)
let darker = min(foregroundLuminance, backgroundLuminance)
return (lighter + 0.05) / (darker + 0.05)
}
private static func relativeLuminance(_ color: UIColor, traits: UITraitCollection) -> CGFloat {
let components = Self.rgbComponents(color, traits: traits)
func linearize(_ component: CGFloat) -> CGFloat {
component <= 0.04045
? component / 12.92
: pow((component + 0.055) / 1.055, 2.4)
}
return 0.2126 * linearize(components.red) + 0.7152 * linearize(components.green) +
0.0722 * linearize(components.blue)
}
private static func rgbComponents(
_ color: UIColor,
traits: UITraitCollection) -> (red: CGFloat, green: CGFloat, blue: CGFloat)
{
let resolved = color.resolvedColor(with: traits)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard resolved.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return (0, 0, 0)
}
return (red, green, blue)
}
}

View File

@@ -0,0 +1,409 @@
import CoreText
import Foundation
import Testing
import UIKit
@testable import OpenClaw
struct OpenClawTypographyTests {
@Test func `bundled fonts load from app bundle`() {
for name in OpenClawType.registeredPostScriptNames {
#expect(UIFont(name: name, size: 12) != nil, "Missing bundled font: \(name)")
}
}
@Test func `dynamic type scales display fonts`() {
guard let base = UIFont(name: "RedHatDisplay-Regular", size: 34) else {
Issue.record("RedHatDisplay-Regular should be bundled")
return
}
let defaultTraits = UITraitCollection(preferredContentSizeCategory: .large)
let largeTraits = UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge)
let metrics = UIFontMetrics(forTextStyle: .largeTitle)
let defaultSize = metrics.scaledFont(for: base, compatibleWith: defaultTraits).pointSize
let largeSize = metrics.scaledFont(for: base, compatibleWith: largeTraits).pointSize
#expect(largeSize > defaultSize)
}
@Test func `display variable weight axis instantiates heavy weights`() {
guard UIFont(name: "RedHatDisplay-Regular", size: 15) != nil else {
Issue.record("RedHatDisplay-Regular should be bundled")
return
}
let weightAxis = NSNumber(value: 2_003_265_652) // "wght"
let descriptor = UIFontDescriptor(fontAttributes: [
.name: "RedHatDisplay-Regular",
kCTFontVariationAttribute as UIFontDescriptor.AttributeName: [weightAxis: 900],
])
let font = UIFont(descriptor: descriptor, size: 15)
let variations = font.fontDescriptor.object(
forKey: kCTFontVariationAttribute as UIFontDescriptor.AttributeName) as? [NSNumber: Any]
let weightValue = variations?[weightAxis] as? NSNumber
#expect(weightValue?.doubleValue == 900)
}
@Test func `app extensions register bundled branded fonts`() throws {
let project = try String(contentsOf: Self.projectYmlURL(), encoding: .utf8)
let activityPlist = try String(contentsOf: Self.activityWidgetInfoPlistURL(), encoding: .utf8)
let watchPlist = try String(contentsOf: Self.watchInfoPlistURL(), encoding: .utf8)
for targetName in ["OpenClawActivityWidget", "OpenClawWatchApp"] {
let target = try Self.extract(
project,
from: " \(targetName):",
to: targetName == "OpenClawActivityWidget" ? " OpenClawWatchApp:" : " OpenClawTests:")
#expect(target.contains("- path: Sources/Fonts"))
#expect(target.contains("UIAppFonts:"))
for font in Self.bundledFontFiles {
#expect(target.contains("- \(font)"))
}
}
for plist in [activityPlist, watchPlist] {
#expect(plist.contains("<key>UIAppFonts</key>"))
for font in Self.bundledFontFiles {
#expect(plist.contains("<string>\(font)</string>"))
}
}
}
@Test func `extension text surfaces use branded typography helpers`() throws {
let activityTypeSource = try String(contentsOf: Self.activityWidgetTypographySourceURL(), encoding: .utf8)
let activitySource = try String(contentsOf: Self.activityWidgetSourceURL(), encoding: .utf8)
let watchTypeSource = try String(contentsOf: Self.watchTypographySourceURL(), encoding: .utf8)
let watchSource = try String(contentsOf: Self.watchInboxSourceURL(), encoding: .utf8)
#expect(activityTypeSource.contains("relativeTo: .subheadline"))
#expect(activityTypeSource.contains("relativeTo: .caption"))
#expect(!activityTypeSource.contains(".custom(\"RedHatDisplay-Regular\", size: size).weight"))
#expect(!activityTypeSource.contains(".custom(\"Inter-Regular\", size: size)"))
#expect(activitySource.contains("OpenClawActivityType.subheadSemiBold"))
#expect(activitySource.contains("OpenClawActivityType.subheadBold"))
#expect(activitySource.contains("OpenClawActivityType.caption"))
#expect(!activitySource.contains(".font(.subheadline"))
#expect(!activitySource.contains(".font(.caption"))
#expect(watchTypeSource.contains("relativeTo textStyle: Font.TextStyle"))
#expect(watchTypeSource.contains("body(size: 12, weight: .semibold, relativeTo: .caption)"))
#expect(watchTypeSource.contains("body(size: 12, weight: .bold, relativeTo: .caption)"))
#expect(watchTypeSource.contains("body(size: 11, relativeTo: .caption2)"))
#expect(watchTypeSource.contains("relativeTo: .caption2"))
#expect(watchTypeSource.contains("relativeTo: .headline"))
#expect(!watchTypeSource.contains(".custom(\"RedHatDisplay-Regular\", size: size).weight"))
#expect(!watchTypeSource.contains(".custom(\"Inter-Regular\", size: size).weight"))
#expect(watchSource.contains("WatchClawType.title"))
#expect(watchSource.contains("WatchClawType.body"))
#expect(watchSource.contains("WatchClawType.caption"))
#expect(!watchSource.contains(".font(.system"))
#expect(!watchSource.contains(".font(.caption"))
#expect(!watchSource.contains(".font(.title"))
}
@Test func `UIKit typography refreshes when Dynamic Type changes`() throws {
let appSource = try String(contentsOf: Self.appSourceURL(), encoding: .utf8)
let typographySource = try String(
contentsOf: Self.sourceURL("Design/OpenClawTypography.swift"),
encoding: .utf8)
#expect(appSource.contains("UIContentSizeCategory.didChangeNotification"))
#expect(appSource.contains("OpenClawType.refreshUIKitAppearance(in: Self.connectedWindows())"))
#expect(typographySource.contains("static func refreshUIKitAppearance(in windows: [UIWindow])"))
#expect(typographySource.contains("applyUIKitTypography(fonts, to: window)"))
#expect(typographySource.contains("maximumPointSize: 13"))
#expect(typographySource.contains("maximumPointSize: 16"))
#expect(typographySource.contains("maximumPointSize: 22"))
#expect(typographySource.contains("maximumPointSize: 44"))
#expect(typographySource.contains("scaledFont(for: base, maximumPointSize: maximumPointSize)"))
#expect(typographySource.contains("case let searchTextField as UISearchTextField"))
#expect(!typographySource.contains("case let textField as UITextField"))
#expect(!typographySource.contains("case let textView as UITextView"))
}
@Test func `listed iOS app surfaces enforce branded control typography`() throws {
let proComponents = try String(
contentsOf: Self.sourceURL("Design/OpenClawProComponents.swift"),
encoding: .utf8)
let quickSetup = try String(contentsOf: Self.sourceURL("Gateway/GatewayQuickSetupSheet.swift"), encoding: .utf8)
let gatewayProblem = try String(contentsOf: Self.sourceURL("Gateway/GatewayProblemView.swift"), encoding: .utf8)
let onboardingSteps = try String(
contentsOf: Self.sourceURL("Onboarding/OnboardingWizardSteps.swift"),
encoding: .utf8)
let onboardingWizard = try String(
contentsOf: Self.sourceURL("Onboarding/OnboardingWizardView.swift"),
encoding: .utf8)
let settingsSections = try String(
contentsOf: Self.sourceURL("Design/SettingsProTabSections.swift"),
encoding: .utf8)
let privacyAccess = try String(
contentsOf: Self.sourceURL("Settings/PrivacyAccessSectionView.swift"),
encoding: .utf8)
let skillWorkshop = try String(
contentsOf: Self.sourceURL("Design/IPadSkillWorkshopScreen.swift"),
encoding: .utf8)
let agentDestinations = try String(
contentsOf: Self.sourceURL("Design/AgentProTab+Destinations.swift"),
encoding: .utf8)
let dreaming = try String(
contentsOf: Self.sourceURL("Design/AgentProDreamingDestination.swift"),
encoding: .utf8)
let instances = try String(contentsOf: Self.sourceURL("Design/AgentProNodesDestination.swift"), encoding: .utf8)
let channels = try String(
contentsOf: Self.sourceURL("Design/SettingsChannelsDestination.swift"),
encoding: .utf8)
let docs = try String(contentsOf: Self.sourceURL("Design/OpenClawDocsScreen.swift"), encoding: .utf8)
let chatTypography = try String(
contentsOf: Self.iosRootURL()
.deletingLastPathComponent()
.appendingPathComponent("shared/OpenClawKit/Sources/OpenClawChatUI/ChatTypography.swift"),
encoding: .utf8)
let chatMessageViews = try String(
contentsOf: Self.iosRootURL()
.deletingLastPathComponent()
.appendingPathComponent("shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift"),
encoding: .utf8)
#expect(proComponents.contains(".font(OpenClawType.subheadSemiBold)"))
#expect(proComponents.contains("Text(primaryActionTitle)"))
#expect(proComponents.contains("Text(secondaryActionTitle)"))
#expect(proComponents.contains("@Environment(\\.isEnabled)"))
#expect(proComponents.contains(".animation(.easeOut(duration: 0.15), value: self.isEnabled)"))
#expect(!quickSetup.contains("Button(\"Close\")"))
#expect(quickSetup.contains("Text(\"Quick Setup\")"))
#expect(quickSetup.contains("Text(\"Close\")"))
#expect(quickSetup.contains(".font(OpenClawType.subheadSemiBold)"))
#expect(gatewayProblem.contains("Text(\"Connection problem\")"))
#expect(gatewayProblem.contains("Text(\"Copy request ID\")"))
#expect(gatewayProblem.contains("Text(\"Copy command\")"))
#expect(gatewayProblem.contains(".font(OpenClawType.subheadSemiBold)"))
#expect(onboardingSteps.contains("Text(\"Connect Gateway\")"))
#expect(onboardingSteps.contains("Label(\"Scan QR Code\", systemImage: \"qrcode\")"))
#expect(onboardingSteps.contains("Text(\"Set Up Manually\")"))
#expect(onboardingSteps.contains(".font(OpenClawType.headline)"))
#expect(onboardingWizard.contains("Text(\"Scan QR Code\")"))
#expect(onboardingWizard.contains("Label(\"Resume After Approval\", systemImage: \"arrow.clockwise\")"))
#expect(onboardingWizard.contains("Label(\"Scan QR Code Again\", systemImage: \"qrcode.viewfinder\")"))
#expect(onboardingWizard.contains("Text(\"Apply Setup Code\")"))
#expect(onboardingWizard.contains(".font(OpenClawType.subheadSemiBold)"))
#expect(settingsSections.contains(".font(OpenClawType.body)"))
#expect(settingsSections.contains("Toggle(isOn: self.$talkButtonEnabled)"))
#expect(settingsSections.contains("Text(\"Show Talk Control\")"))
#expect(settingsSections.contains("TextField(\"Default Share Instruction\""))
#expect(settingsSections.contains(".font(OpenClawType.subhead)"))
#expect(settingsSections.contains("private struct AppearanceSettingsScreen"))
#expect(settingsSections.contains("Section(\"Gateway\")"))
#expect(settingsSections.contains("LabeledContent(\"Address\", value: self.gatewayAddress)"))
#expect(settingsSections.contains("func gatewayActionButton"))
#expect(settingsSections.contains("func settingsToggle"))
#expect(settingsSections.contains(".font(OpenClawType.subheadSemiBold)"))
#expect(settingsSections.contains("Text(\"Use Manual Gateway\")")
|| settingsSections.contains("\"Use Manual Gateway\""))
#expect(settingsSections.contains("func gatewaySecureField"))
#expect(settingsSections.contains("self.gatewaySecureField(\"Gateway Auth Token\""))
#expect(settingsSections.contains("self.gatewaySecureField(\"Gateway Password\""))
let gatewaySecureField = try Self.extract(
settingsSections,
from: "func gatewaySecureField",
to: " var voiceFeatureCard")
#expect(gatewaySecureField.contains(".accessibilityLabel(placeholder)"))
#expect(gatewaySecureField.contains(".accessibilityHidden(true)"))
#expect(gatewaySecureField.contains(".textInputAutocapitalization(.never)"))
#expect(gatewaySecureField.contains(".autocorrectionDisabled()"))
#expect(settingsSections.contains("Picker(\"Default Agent\", selection: self.$selectedAgentPickerId)"))
#expect(settingsSections.contains("Text(\"Default\")"))
#expect(!privacyAccess.contains("DisclosureGroup(\"Privacy & Access\")"))
#expect(privacyAccess.contains("Text(\"Privacy & Access\")"))
#expect(privacyAccess.contains("Text(actionTitle)"))
#expect(privacyAccess.contains(".font(OpenClawType.footnoteSemiBold)"))
#expect(!skillWorkshop.contains("Button(\"Done\")"))
#expect(skillWorkshop.contains("Label(\"Refresh\", systemImage: \"arrow.clockwise\")"))
#expect(skillWorkshop.contains("Text(\"Default agent\")"))
#expect(skillWorkshop.contains("Text(\"Inspect\")"))
#expect(skillWorkshop.contains("Text(\"Apply\")"))
#expect(skillWorkshop.contains("Text(\"Reject\")"))
for source in [agentDestinations, dreaming, instances, channels, docs] {
#expect(source.contains(".font(OpenClawType.body)"))
}
#expect(chatMessageViews.contains("font: OpenClawChatTypography.body"))
#expect(chatMessageViews.contains("OpenClawChatTypography.callout.italic()"))
#expect(!chatMessageViews.contains("font: .body"))
#expect(!chatMessageViews.contains("Font.body"))
#expect(!chatMessageViews.contains("Font.callout"))
#expect(chatTypography
.contains("Font.custom(self.macSystemFontName(size: size), size: size, relativeTo: textStyle)"))
#expect(chatTypography.contains(
"Font.custom(self.macMonospacedSystemFontName(size: size), size: size, relativeTo: textStyle)"))
#expect(!chatTypography.contains("Font.system(textStyle, design: .default)"))
#expect(!chatTypography.contains("Font.system(textStyle, design: .monospaced)"))
}
@Test func `iOS app text and control calls keep branded font boundaries`() throws {
let offenders = try Self.unbrandedTextCallOffenders()
#expect(offenders.isEmpty, Comment(rawValue: offenders.joined(separator: "\n")))
}
@Test func `secure fields do not use platform placeholder text`() throws {
let offenders = try Self.swiftSourcesForTypographyAudit().flatMap { url -> [String] in
let source = try String(contentsOf: url, encoding: .utf8)
return source
.components(separatedBy: .newlines)
.enumerated()
.compactMap { offset, line in
guard line.range(
of: #"\bSecureField\("[^"]+""#,
options: .regularExpression) != nil
else { return nil }
return "\(Self.relativePath(url)):\(offset + 1): \(line.trimmingCharacters(in: .whitespaces))"
}
}
#expect(offenders.isEmpty, Comment(rawValue: offenders.joined(separator: "\n")))
}
private static let bundledFontFiles = [
"RedHatDisplay[wght].ttf",
"Inter[opsz,wght].ttf",
"Inter-Italic[opsz,wght].ttf",
"JetBrainsMono-Regular.ttf",
"JetBrainsMono-Medium.ttf",
"JetBrainsMono-SemiBold.ttf",
]
private static func projectYmlURL() -> URL {
self.iosRootURL().appendingPathComponent("project.yml")
}
private static func activityWidgetInfoPlistURL() -> URL {
self.iosRootURL().appendingPathComponent("ActivityWidget/Info.plist")
}
private static func watchInfoPlistURL() -> URL {
self.iosRootURL().appendingPathComponent("WatchApp/Info.plist")
}
private static func activityWidgetSourceURL() -> URL {
self.iosRootURL().appendingPathComponent("ActivityWidget/OpenClawLiveActivity.swift")
}
private static func activityWidgetTypographySourceURL() -> URL {
self.iosRootURL().appendingPathComponent("ActivityWidget/OpenClawActivityTypography.swift")
}
private static func watchInboxSourceURL() -> URL {
self.iosRootURL().appendingPathComponent("WatchApp/Sources/WatchInboxView.swift")
}
private static func watchTypographySourceURL() -> URL {
self.iosRootURL().appendingPathComponent("WatchApp/Sources/WatchClawTypography.swift")
}
private static func appSourceURL() -> URL {
self.sourceURL("OpenClawApp.swift")
}
private static func sourceURL(_ relativePath: String) -> URL {
self.iosRootURL().appendingPathComponent("Sources/\(relativePath)")
}
private static func swiftSourcesForTypographyAudit() throws -> [URL] {
let roots = [
self.sourceURL(""),
self.iosRootURL()
.deletingLastPathComponent()
.appendingPathComponent("shared/OpenClawKit/Sources/OpenClawChatUI"),
]
return roots.flatMap { root -> [URL] in
guard let enumerator = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: nil)
else { return [] }
return enumerator.compactMap { item -> URL? in
guard let url = item as? URL, url.pathExtension == "swift" else { return nil }
return url
}
}
.sorted { $0.path < $1.path }
}
private static func unbrandedTextCallOffenders() throws -> [String] {
let fontTokens = ["OpenClawType", "OpenClawChatTypography"]
let allowedFragments = [".navigationTitle(", ".alert(\"", ".tabItem { Label("]
return try self.swiftSourcesForTypographyAudit().flatMap { url -> [String] in
let source = try String(contentsOf: url, encoding: .utf8)
let lines = source.components(separatedBy: .newlines)
return lines.indices.compactMap { idx -> String? in
let rawLine = lines[idx]
let line = rawLine.trimmingCharacters(in: .whitespaces)
guard !line.hasPrefix("//") else { return nil }
guard !allowedFragments.contains(where: rawLine.contains) else { return nil }
let window = lines[idx..<min(lines.count, idx + 12)].joined(separator: "\n")
let hasLocalFont = fontTokens.contains { window.contains($0) }
|| self.hasAllowedBrandedFontParameter(window, in: url)
if self.isTextOrLabelCall(rawLine), !hasLocalFont {
return "\(self.relativePath(url)):\(idx + 1): \(line)"
}
if self.isShorthandControlCall(rawLine), !hasLocalFont {
return "\(self.relativePath(url)):\(idx + 1): \(line)"
}
return nil
}
}
}
private static func isTextOrLabelCall(_ line: String) -> Bool {
line.range(of: #"\b(Text|Label)\s*\("#, options: .regularExpression) != nil
}
private static func isShorthandControlCall(_ line: String) -> Bool {
line.range(
of: #"\b(Button|Link|Picker|Toggle|TextField|SecureField|Menu|DisclosureGroup)\s*\(""#,
options: .regularExpression) != nil
}
private static func hasAllowedBrandedFontParameter(_ window: String, in url: URL) -> Bool {
switch self.relativePath(url) {
case "apps/ios/Sources/Design/OpenClawProComponents.swift":
window.contains(".font(self.titleFont)") || window.contains(".font(self.subtitleFont)")
case "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift":
window.contains(".font(self.font)")
default:
false
}
}
private static func relativePath(_ url: URL) -> String {
let rootPath = self.iosRootURL()
.deletingLastPathComponent()
.deletingLastPathComponent()
.path + "/"
return url.path.hasPrefix(rootPath) ? String(url.path.dropFirst(rootPath.count)) : url.path
}
private static func iosRootURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
}
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
let startRange = try #require(source.range(of: start))
let tail = source[startRange.lowerBound...]
let endRange = try #require(tail.range(of: end))
return String(tail[..<endRange.lowerBound])
}
}

View File

@@ -0,0 +1,39 @@
import Photos
import Testing
@testable import OpenClaw
@Suite(.serialized) struct PermissionRequestBridgeTests {
@Test func `box resumes immediately when cancelled before install`() async {
let box = PermissionRequestBridge.Box()
box.resume(false)
let granted: Bool = await withCheckedContinuation { continuation in
_ = box.install(continuation)
}
#expect(granted == false)
#expect(box.canStartRequest() == false)
}
@Test func `box resumes installed continuation once`() async {
let box = PermissionRequestBridge.Box()
let granted: Bool = await withCheckedContinuation { continuation in
_ = box.install(continuation)
box.resume(true)
box.resume(false)
}
#expect(granted == true)
}
}
struct PhotoLibraryAccessTests {
@Test(arguments: [PHAuthorizationStatus.authorized, .limited])
func `read access includes full and limited authorization`(_ status: PHAuthorizationStatus) {
#expect(PhotoLibraryAccess.canRead(status))
}
@Test(arguments: [PHAuthorizationStatus.notDetermined, .denied, .restricted])
func `read access excludes unavailable authorization`(_ status: PHAuthorizationStatus) {
#expect(!PhotoLibraryAccess.canRead(status))
}
}

View File

@@ -0,0 +1,50 @@
import Foundation
import Testing
@testable import OpenClaw
struct PushBuildConfigTests {
@Test func `app store mode derives production relay contract`() {
let config = PushBuildConfig(infoDictionary: [
"OpenClawPushMode": "appStore",
"OpenClawPushRelayBaseURL": "https://wrong.example.com",
])
#expect(config.mode == .appStore)
#expect(config.transport == .relay)
#expect(config.distribution == .official)
#expect(config.relayBaseURL?.absoluteString == "https://ios-push-relay.openclaw.ai")
#expect(config.apnsEnvironment == .production)
#expect(config.relayProfile == .production)
#expect(config.proofPolicy == .appleStrict)
}
@Test func `simulator sandbox mode derives internal proof contract`() {
let config = PushBuildConfig(infoDictionary: [
"OpenClawPushMode": "simulatorSandbox",
"OpenClawPushRelayBaseURL": "https://staging-relay.example.com",
])
#expect(config.mode == .simulatorSandbox)
#expect(config.transport == .relay)
#expect(config.distribution == .official)
#expect(config.relayBaseURL?.absoluteString == "https://staging-relay.example.com")
#expect(config.apnsEnvironment == .sandbox)
#expect(config.relayProfile == .simulatorSandbox)
#expect(config.proofPolicy == .internalSimulator)
}
@Test func `local release mode remains direct production push`() {
let config = PushBuildConfig(infoDictionary: [
"OpenClawPushMode": "localProduction",
"OpenClawPushRelayBaseURL": "https://ios-push-relay.openclaw.ai",
])
#expect(config.mode == .localProduction)
#expect(config.transport == .direct)
#expect(config.distribution == .local)
#expect(config.relayBaseURL == nil)
#expect(config.apnsEnvironment == .production)
#expect(config.relayProfile == .production)
#expect(config.proofPolicy == .appleStrict)
}
}

View File

@@ -0,0 +1,107 @@
import Foundation
import OpenClawKit
import OpenClawProtocol
import Testing
@testable import OpenClaw
@MainActor
private final class UnusedPCMStreamingAudioPlayer: PCMStreamingAudioPlaying {
func play(stream: AsyncThrowingStream<Data, Error>, sampleRate: Double) async -> StreamingPlaybackResult {
fatalError("Playback is not used by this test")
}
func stop() -> Double? {
nil
}
}
@MainActor
@Suite struct RealtimeTalkRelaySessionTests {
@Test func outputPlaybackFinishClearsBargeInStartTime() {
var speakingStates: [Bool] = []
let session = RealtimeTalkRelaySession(
gateway: GatewayNodeSession(),
options: .init(sessionKey: "main", provider: nil, model: nil, voice: nil),
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
onStatus: { _ in },
onSpeakingChanged: { speakingStates.append($0) })
session._test_markOutputAudioStarted(nowMs: 100)
#expect(session._test_isOutputPlaying())
#expect(session._test_outputStartedAtMs() == 100)
session._test_markOutputPlaybackFinished()
#expect(!session._test_isOutputPlaying())
#expect(session._test_outputStartedAtMs() == nil)
#expect(speakingStates == [false])
session._test_markOutputAudioStarted(nowMs: 500)
#expect(session._test_outputStartedAtMs() == 500)
}
@Test func closeAfterClassifiedErrorDoesNotReplaceIssue() async {
var issues: [TalkRuntimeIssue] = []
var statuses: [String] = []
let session = RealtimeTalkRelaySession(
gateway: GatewayNodeSession(),
options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil),
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
onStatus: { statuses.append($0) },
onIssue: { issues.append($0) },
onSpeakingChanged: { _ in })
session._test_setRelaySessionId("relay-1")
await session._test_handleGatewayEvent(EventFrame(
type: "event",
event: "talk.event",
payload: AnyCodable([
"relaySessionId": "relay-1",
"type": "error",
"message": "OpenAI API key rejected with 401",
"code": "realtime_unavailable",
"provider": "openai",
"model": "gpt-realtime-2",
"transport": "gateway-relay",
"phase": "connect",
]),
seq: nil,
stateversion: nil))
await session._test_handleGatewayEvent(EventFrame(
type: "event",
event: "talk.event",
payload: AnyCodable([
"relaySessionId": "relay-1",
"type": "close",
"reason": "error",
]),
seq: nil,
stateversion: nil))
#expect(issues.map(\.code) == [.realtimeUnavailable])
#expect(statuses == ["OpenAI API key rejected with 401"])
}
@Test func closedRelayDoesNotWaitForStartupReady() async {
let session = RealtimeTalkRelaySession(
gateway: GatewayNodeSession(),
options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil),
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
onStatus: { _ in },
onSpeakingChanged: { _ in })
session.stop()
#expect(await session._test_waitForStartupCancelled(timeoutSeconds: 1))
}
@Test func startupReadyWaitCoversGatewayConnectBudget() {
let session = RealtimeTalkRelaySession(
gateway: GatewayNodeSession(),
options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil),
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
onStatus: { _ in },
onSpeakingChanged: { _ in })
#expect(session._test_startupReadyTimeoutSeconds() >= 12)
}
}

View File

@@ -0,0 +1,540 @@
import SwiftUI
import Testing
import UIKit
@testable import OpenClaw
@MainActor
struct RootTabsPresentationTests {
@Test func `quick setup does not present when gateway already configured`() {
let shouldPresent = RootTabs.shouldPresentQuickSetup(
quickSetupDismissed: false,
showOnboarding: false,
hasPresentedSheet: false,
gatewayConnected: false,
hasExistingGatewayConfig: true,
discoveredGatewayCount: 1)
#expect(!shouldPresent)
}
@Test func `quick setup presents for fresh install with discovered gateway`() {
let shouldPresent = RootTabs.shouldPresentQuickSetup(
quickSetupDismissed: false,
showOnboarding: false,
hasPresentedSheet: false,
gatewayConnected: false,
hasExistingGatewayConfig: false,
discoveredGatewayCount: 1)
#expect(shouldPresent)
}
@Test func `quick setup does not present when already connected`() {
let shouldPresent = RootTabs.shouldPresentQuickSetup(
quickSetupDismissed: false,
showOnboarding: false,
hasPresentedSheet: false,
gatewayConnected: true,
hasExistingGatewayConfig: false,
discoveredGatewayCount: 1)
#expect(!shouldPresent)
}
@Test func `sidebar tabs enabled for I pad regular width`() {
#expect(
RootTabs.shouldUseSidebarTabs(
idiom: .pad,
horizontalSizeClass: .regular))
}
@Test func `sidebar tabs enabled for I pad compact width`() {
#expect(
RootTabs.shouldUseSidebarTabs(
idiom: .pad,
horizontalSizeClass: .compact))
}
@Test func `sidebar tabs disabled for I phone`() {
#expect(
!RootTabs.shouldUseSidebarTabs(
idiom: .phone,
horizontalSizeClass: .regular))
}
@Test func `sidebar groups match adaptive navigation model`() {
let groups = RootTabs.sidebarGroups
let destinationIDs = RootTabs.SidebarDestination.allCases.map(\.rawValue)
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
#expect(groups[0].destinations.map(\.rawValue) == ["chat", "talk"])
#expect(groups[1].destinations == [
.overview,
.activity,
.agents,
.workboard,
.skillWorkshop,
.instances,
.sessions,
.dreaming,
.usage,
.cron,
.terminal,
])
#expect(groups[2].destinations == [.settings])
#expect(groups[3].destinations == [.docs])
#expect(destinationIDs == [
"chat",
"talk",
"overview",
"activity",
"agents",
"workboard",
"skillWorkshop",
"instances",
"sessions",
"dreaming",
"usage",
"cron",
"terminal",
"docs",
"settings",
"gateway",
])
#expect(!destinationIDs.contains("agent"))
#expect(!RootTabs.sidebarGroups.flatMap(\.destinations).contains(.gateway))
}
@Test func `phone control groups avoid duplicating the agent tab`() {
let groups = RootTabs.phoneControlGroups
let destinations = groups.flatMap(\.destinations)
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
#expect(!destinations.contains(.agents))
#expect(RootTabs.sidebarGroups.flatMap(\.destinations).contains(.agents))
#expect(destinations.contains(.dreaming))
#expect(destinations.contains(.instances))
}
@Test func `sidebar uses compact labels for long routes`() {
#expect(RootTabs.SidebarDestination.settings.title == "Settings")
#expect(RootTabs.SidebarDestination.gateway.title == "Settings / Gateway")
#expect(RootTabs.SidebarDestination.gateway.sidebarTitle == "Connection")
}
@Test func `phone hub uses root tabs only for native chat agent and gateway`() {
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.chat))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.talk))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.agents))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.gateway))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.settings))
for destination in RootTabs.SidebarDestination.allCases
where destination != .chat && destination != .talk && destination != .agents && destination != .gateway &&
destination != .settings
{
#expect(!RootTabs.shouldOpenRootTabFromPhoneHub(destination))
}
}
@Test func `app launch defaults to chat tab`() {
#expect(RootTabs.initialTab(arguments: ["OpenClaw"]) == .chat)
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-tab"]) == .chat)
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-tab", "unknown"]) == .chat)
}
@Test func `app launch uses requested destination before chat fallback`() {
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-destination", "overview"]) == .control)
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-destination", "chat"]) == .chat)
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-destination", "agents"]) == .agent)
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-destination", "gateway"]) == .settings)
#expect(
RootTabs.initialTab(arguments: [
"OpenClaw",
"--openclaw-initial-tab",
"unknown",
"--openclaw-initial-destination",
"activity",
]) == .control)
}
@Test func `app launch respects explicit initial tab override`() {
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-tab", "control"]) == .control)
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-tab", "overview"]) == .control)
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-tab", "chat"]) == .chat)
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-tab", "voice"]) == .talk)
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-tab", "agents"]) == .agent)
#expect(RootTabs.initialTab(arguments: ["OpenClaw", "--openclaw-initial-tab", "settings"]) == .settings)
}
@Test func `legacy initial tabs map to matching sidebar destinations`() {
#expect(RootTabs.defaultSidebarDestination(for: .control) == .overview)
#expect(RootTabs.defaultSidebarDestination(for: .chat) == .chat)
#expect(RootTabs.defaultSidebarDestination(for: .talk) == .talk)
#expect(RootTabs.defaultSidebarDestination(for: .agent) == .agents)
#expect(RootTabs.defaultSidebarDestination(for: .settings) == .settings)
}
@Test func `skill workshop mutations require admin scope`() {
#expect(IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: true))
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: false))
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: false, hasOperatorAdminScope: true))
}
@Test func `skill workshop held filter includes quarantined and stale`() {
#expect(IPadSkillWorkshopScreen.proposalStatusFilters.contains("held"))
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "quarantined", filter: "held"))
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "stale", filter: "held"))
#expect(!IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "pending", filter: "held"))
}
@Test func `skill workshop board lanes match status filter`() {
#expect(
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
filter: "pending",
proposalStatuses: ["pending", "applied"]) == ["pending"])
#expect(
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
filter: "held",
proposalStatuses: ["quarantined", "stale"]) == ["quarantined", "stale"])
#expect(
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
filter: "all",
proposalStatuses: ["pending", "needs-review"]) == [
"pending",
"quarantined",
"stale",
"applied",
"rejected",
"needs-review",
])
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("quarantined") == "Quarantined")
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("pending") == "Pending")
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("needs-review") == "Needs Review")
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("manual_QA") == "Manual QA")
}
@Test func `skill workshop selection stays inside active filter`() {
let proposals = [
(id: "applied-1", status: "applied"),
(id: "pending-1", status: "pending"),
(id: "held-1", status: "quarantined"),
]
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "applied-1",
proposals: proposals,
filter: "pending") == "pending-1")
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "held-1",
proposals: proposals,
filter: "held") == "held-1")
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "pending-1",
visibleProposalIDs: ["held-1"]) == "held-1")
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "pending-1",
visibleProposalIDs: []) == nil)
}
@Test func `workboard board scope labels stay compact`() {
#expect(IPadWorkboardScreen.normalizedScopeID(" planning ") == "planning")
#expect(IPadWorkboardScreen.boardScopeLabel(for: "") == "All boards")
#expect(IPadWorkboardScreen.boardScopeLabel(for: "planning") == "planning")
#expect(IPadWorkboardScreen.boardScopeOptions(
knownBoardIDs: ["default", " empty-board ", ""],
cardBoardIDs: ["planning", "default"]) == ["default", "empty-board", "planning"])
#expect(IPadWorkboardScreen
.workboardSubtitle(boardScopeLabel: "All boards", selectedStatus: "active") == "All boards / Active")
#expect(IPadWorkboardScreen
.workboardSubtitle(boardScopeLabel: "planning", selectedStatus: "running") == "planning / Running")
}
@Test func `workboard compact unavailable copy explains real capability state`() {
#expect(IPadWorkboardScreen
.compactWriteUnavailableMessage(canRead: false) ==
"Connect from Settings to create, move, and dispatch cards.")
#expect(IPadWorkboardScreen.compactWriteUnavailableMessage(canRead: true) == "Read-only gateway.")
}
@Test func `skill workshop agent scope normalizes gateway ids`() {
#expect(IPadSkillWorkshopScreen.normalizedScopeID(" aiden ") == "aiden")
#expect(IPadSkillWorkshopScreen.normalizedScopeID(nil) == "")
}
@Test func `channel lifecycle controls require admin scope`() {
#expect(SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: true))
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: false))
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: false, hasOperatorAdminScope: true))
}
@Test func `click clack stays in channels integration metadata`() {
#expect(SettingsChannelsDestination.fallbackLabel("clickclack") == "ClickClack")
#expect(SettingsChannelsDestination.fallbackDetail("clickclack") == "Self-hosted chat bot routing.")
#expect(SettingsChannelsDestination.fallbackSystemImage("clickclack") == "bubble.left.and.bubble.right")
}
@Test func `i pad overview can suppress standalone header branding`() {
#expect(CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: true))
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: true, showsHeaderMark: true))
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: false))
}
@Test func `command center can use parent navigation stack for embedded routes`() {
let standalone = CommandCenterTab(openChat: {}, openSettings: {})
let embedded = CommandCenterTab(
ownsNavigationStack: false,
openChat: {},
openSettings: {})
let native = CommandCenterTab(
ownsNavigationStack: false,
usesNativeNavigationChrome: true,
openChat: {},
openSettings: {})
let shellRouted = CommandCenterTab(
ownsNavigationStack: false,
openChat: {},
openSettings: {},
openSessions: {})
#expect(standalone.ownsNavigationStack)
#expect(standalone.openSessions == nil)
#expect(!embedded.ownsNavigationStack)
#expect(!embedded.usesNativeNavigationChrome)
#expect(embedded.openSessions == nil)
#expect(native.usesNativeNavigationChrome)
#expect(shellRouted.openSessions != nil)
}
@Test func `chat sidebar destination can use native route title instead of agent branding`() {
let standalone = ChatProTab()
let routed = ChatProTab(
headerTitle: "Chat",
showsAgentBadge: false,
ownsNavigationStack: false,
openSettings: {})
#expect(standalone.showsAgentBadge)
#expect(standalone.ownsNavigationStack)
#expect(standalone.headerTitle == nil)
#expect(standalone.openSettings == nil)
#expect(routed.headerTitle == "Chat")
#expect(!routed.showsAgentBadge)
#expect(!routed.ownsNavigationStack)
#expect(routed.openSettings != nil)
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: true, agentDisplayName: "OpenClaw") == "OpenClaw")
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: false, agentDisplayName: "OpenClaw") == "Chat")
}
@Test func `agent routes can open gateway settings from header pill`() {
let standalone = AgentProTab()
let routed = AgentProTab(
directRoute: .instances,
headerTitle: "Instances",
openSettings: {})
#expect(standalone.headerTitle == "Agents")
#expect(standalone.directRoute == nil)
#expect(standalone.openSettings == nil)
#expect(AgentProTab(directRoute: .agents).directRoute == .agents)
#expect(routed.directRoute == .instances)
#expect(routed.headerTitle == "Instances")
#expect(routed.openSettings != nil)
}
@Test func `workboard dispatch summary reports started and failures`() throws {
let payload = Data(
"""
{
"count": 2,
"started": [{}],
"startFailures": [{}],
"promoted": [],
"reclaimed": [],
"blocked": [],
"orchestrated": []
}
""".utf8)
let summary = try JSONDecoder().decode(IPadWorkboardDispatchSummary.self, from: payload)
#expect(summary.summaryText == "2 dispatched: 1 started, 1 failed.")
}
@Test func `talk sidebar destination can receive reveal action`() {
let action = OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Show Sidebar",
action: {})
let routed = TalkProTab(headerLeadingAction: action, openSettings: {})
let embedded = TalkProTab(
headerLeadingAction: action,
ownsNavigationStack: false,
openSettings: {})
#expect(routed.headerLeadingAction?.systemName == "sidebar.left")
#expect(routed.headerLeadingAction?.accessibilityLabel == "Show Sidebar")
#expect(routed.ownsNavigationStack)
#expect(!embedded.ownsNavigationStack)
}
@Test func `settings can use parent navigation stack for sidebar routes`() {
let standalone = SettingsProTab()
let embedded = SettingsProTab(ownsNavigationStack: false)
#expect(standalone.ownsNavigationStack)
#expect(!embedded.ownsNavigationStack)
}
@Test func `i pad portrait uses hidden drawer sidebar`() {
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1024, height: 1366))
#expect(mode == .drawer)
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
}
@Test func `i pad wide landscape uses visible split sidebar`() {
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1366, height: 1024))
#expect(mode == .split)
#expect(RootTabs.preferredSidebarVisibility(layoutMode: mode))
}
@Test func `i pad split sidebar width stays usable`() {
let width = RootTabs.sidebarWidth(containerWidth: 1366, isDrawerLayout: false)
#expect(width >= RootTabs.sidebarSplitIdealWidth)
#expect(width <= RootTabs.sidebarSplitMaximumWidth)
}
@Test func `i pad collapsed split sidebar uses header reveal without reserved rail`() {
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .split))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .split))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .drawer))
#expect(
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .drawer))
}
@Test func `initial sidebar visibility parses launch argument`() {
#expect(
RootTabs.requestedInitialSidebarVisibility(arguments: [
"OpenClaw",
"--openclaw-sidebar-visibility",
"hidden",
]) == false)
#expect(
RootTabs.requestedInitialSidebarVisibility(arguments: [
"OpenClaw",
"--openclaw-sidebar-visibility",
"visible",
]) == true)
#expect(
RootTabs.requestedInitialSidebarVisibility(arguments: [
"OpenClaw",
"--openclaw-sidebar-visibility",
"unknown",
]) == nil)
}
@Test func `sidebar controls have stable accessibility identifiers`() {
#expect(RootTabs.sidebarShowButtonAccessibilityIdentifier == "RootTabs.Sidebar.Show")
#expect(RootTabs.sidebarHideButtonAccessibilityIdentifier == "RootTabs.Sidebar.Hide")
}
@Test func `i pad drawer sidebar width stays inside screen`() {
let width = RootTabs.sidebarWidth(containerWidth: 744, isDrawerLayout: true)
#expect(width >= 280)
#expect(width <= RootTabs.sidebarDrawerMaximumWidth)
}
@Test func `narrow landscape keeps drawer sidebar`() {
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 900, height: 600))
#expect(mode == .drawer)
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
}
@Test func `drawer selection collapses sidebar but split selection does not`() {
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
}
@Test func `hidden sidebar shows reveal control`() {
#expect(RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: false))
}
@Test func `sidebar reveal controls hide when sidebar is visible`() {
#expect(!RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: true))
}
@Test func `i pad split prefers integrated visible sidebar`() {
#expect(RootTabs.preferredSidebarVisibility(layoutMode: .split))
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: .drawer))
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
}
@Test func `destination headers own hidden sidebar reveal control`() {
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .drawer))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .split))
#expect(
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .drawer))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .split))
}
@Test func `workboard and skill workshop use compact task flow on phone sizes`() {
#expect(
IPadWorkboardScreen.usesCompactTaskFlow(
horizontalSizeClass: .compact,
verticalSizeClass: .regular))
#expect(
IPadSkillWorkshopScreen.usesCompactTaskFlow(
horizontalSizeClass: .compact,
verticalSizeClass: .regular))
#expect(
IPadWorkboardScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .compact))
#expect(
IPadSkillWorkshopScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .compact))
}
@Test func `workboard and skill workshop keep regular task flow on wide I pad sizes`() {
#expect(
!IPadWorkboardScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .regular))
#expect(
!IPadSkillWorkshopScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .regular))
}
}

View File

@@ -0,0 +1,142 @@
import Foundation
import Testing
@Suite struct RootTabsSidebarRegressionTests {
@Test func iPadSplitHiddenSidebarUsesHeaderRevealInsteadOfReservedRail() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
let splitContent = try Self.extract(
source,
from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View",
to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View")
#expect(splitContent.contains("HStack(spacing: 0)"))
#expect(splitContent.contains("self.sidebarColumn"))
#expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)"))
#expect(splitContent.contains(".overlay(alignment: .trailing)"))
#expect(!splitContent.contains("self.syncSidebarVisibility(from: visibility)"))
#expect(!source.contains("NavigationSplitViewVisibility"))
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
#expect(!splitContent.contains("NavigationSplitView"))
#expect(!splitContent.contains("self.collapsedSidebarRail"))
#expect(!source.contains("private var collapsedSidebarRail: some View"))
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
#expect(source.contains("shouldShowSidebarRevealInDestinationHeader"))
#expect(!navigationSource.contains("static let sidebarCollapsedRailWidth"))
#expect(!navigationSource.contains("static func sidebarSplitColumnVisibility(isSidebarVisible: Bool)"))
#expect(!navigationSource
.contains("static func sidebarIsVisible(splitColumnVisibility: NavigationSplitViewVisibility)"))
}
@Test func initialSidebarVisibilitySurvivesFirstLayoutMeasurement() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let layoutUpdate = try Self.extract(
source,
from: "private func updateSidebarLayout(containerSize: CGSize, force: Bool)",
to: "private func setSidebarVisible(_ isVisible: Bool)")
#expect(source.contains("@State private var didResolveSidebarLayout: Bool = false"))
#expect(layoutUpdate.contains("let didResolvePreviousLayout = self.didResolveSidebarLayout"))
#expect(layoutUpdate.contains("self.didResolveSidebarLayout = true"))
#expect(layoutUpdate.contains("if layoutModeDidChange && didResolvePreviousLayout"))
#expect(layoutUpdate.contains("guard force || !self.sidebarVisibilityUserOverridden else { return }"))
}
@Test func drawerDimmingLayerDoesNotStealSidebarTouches() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let drawerContent = try Self.extract(
source,
from: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View",
to: "private var sidebarDetailShell: some View")
#expect(drawerContent.contains("HStack(spacing: 0)"))
#expect(drawerContent.contains("Color.clear"))
#expect(drawerContent.contains(".frame(width: sidebarWidth)"))
#expect(drawerContent.contains(".allowsHitTesting(false)"))
#expect(drawerContent.contains("Color.black.opacity(0.28)"))
#expect(drawerContent.contains(".zIndex(0)"))
#expect(drawerContent.contains(".zIndex(1)"))
}
@Test func sidebarSelectionResetsEmbeddedSettingsNavigationPath() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let sidebarDetail = try Self.extract(
source,
from: "private var sidebarDetail: some View",
to: "private var sidebarDetailNavigationShell: some View")
let navigationShell = try Self.extract(
source,
from: "private var sidebarDetailNavigationShell: some View",
to: "private var usesSidebarTabs: Bool")
let selection = try Self.extract(
source,
from: "private func selectSidebarDestination(",
to: "private func showSidebar()")
let resetRange = try #require(selection.range(of: "self.sidebarNavigationPath.removeAll()"))
let destinationRange = try #require(selection.range(of: "self.selectedSidebarDestination = destination"))
#expect(source.contains("@State private var sidebarNavigationPath: [SettingsRoute] = []"))
#expect(navigationShell.contains("NavigationStack(path: self.$sidebarNavigationPath)"))
#expect(sidebarDetail.contains("case .settings:"))
#expect(sidebarDetail.contains("ownsNavigationStack: false"))
#expect(resetRange.lowerBound < destinationRange.lowerBound)
}
@Test func embeddedOverviewRoutesViewMoreThroughOwningNavigationStack() throws {
let rootTabsSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let commandCenterSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
let phoneHubSource = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
let sidebarDetail = try Self.extract(
rootTabsSource,
from: "private var sidebarDetail: some View",
to: "private var sidebarDetailNavigationShell: some View")
let iPadOverview = try Self.extract(sidebarDetail, from: "case .overview:", to: "case .activity:")
let recentSessions = try Self.extract(
commandCenterSource,
from: "private var recentSessions: some View",
to: "private func cardHeader(")
let phoneOverview = try Self.extract(phoneHubSource, from: "case .overview:", to: "case .activity:")
#expect(commandCenterSource.contains("var openSessions: (() -> Void)?"))
#expect(recentSessions.contains("if let openSessions"))
#expect(recentSessions.contains("Button(action: openSessions)"))
#expect(recentSessions.contains("NavigationLink"))
#expect(iPadOverview.contains("openSessions: { self.selectSidebarDestination(.sessions) }"))
#expect(phoneOverview.contains("openSessions: { self.navigationPath.append(.sessions) }"))
}
private static func rootTabsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/RootTabs.swift")
}
private static func rootTabsNavigationSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/RootTabsNavigation.swift")
}
private static func commandCenterSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/CommandCenterTab.swift")
}
private static func phoneHubSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/RootTabsPhoneControlHub.swift")
}
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
let startRange = try #require(source.range(of: start))
let tail = source[startRange.lowerBound...]
let endRange = try #require(tail.range(of: end))
return String(tail[..<endRange.lowerBound])
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
import Testing
import WebKit
@testable import OpenClaw
@MainActor
private func mountScreen(_ screen: ScreenController) throws -> (ScreenWebViewCoordinator, WKWebView) {
let coordinator = ScreenWebViewCoordinator(controller: screen)
_ = coordinator.makeContainerView()
let webView = try #require(coordinator.managedWebView)
return (coordinator, webView)
}
@Suite struct ScreenControllerTests {
@Test @MainActor func canvasModeConfiguresWebViewForTouch() throws {
let screen = ScreenController()
let (coordinator, webView) = try mountScreen(screen)
defer { coordinator.teardown() }
#expect(webView.isOpaque == true)
#expect(webView.backgroundColor == .black)
let scrollView = webView.scrollView
#expect(scrollView.backgroundColor == .black)
#expect(scrollView.contentInsetAdjustmentBehavior == .never)
#expect(scrollView.isScrollEnabled == false)
#expect(scrollView.bounces == false)
}
@Test @MainActor func navigateEnablesScrollForWebPages() throws {
let screen = ScreenController()
let (coordinator, webView) = try mountScreen(screen)
defer { coordinator.teardown() }
screen.navigate(to: "https://example.com")
let scrollView = webView.scrollView
#expect(scrollView.isScrollEnabled == true)
#expect(scrollView.bounces == true)
}
@Test @MainActor func navigateSlashShowsDefaultCanvas() {
let screen = ScreenController()
screen.navigate(to: "/")
#expect(screen.urlString.isEmpty)
}
@Test @MainActor func canvasPresentationTracksExplicitPresentAndHide() {
let screen = ScreenController()
#expect(screen.isCanvasPresented == false)
screen.showDefaultCanvas()
#expect(screen.isCanvasPresented == false)
screen.presentDefaultCanvas()
#expect(screen.isCanvasPresented == true)
#expect(screen.urlString.isEmpty)
screen.hideCanvas()
#expect(screen.isCanvasPresented == false)
#expect(screen.urlString.isEmpty)
}
@Test @MainActor func evalExecutesJavaScript() async throws {
let screen = ScreenController()
let (coordinator, _) = try mountScreen(screen)
defer { coordinator.teardown() }
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while true {
do {
let result = try await screen.eval(javaScript: "1+1")
#expect(result == "2")
return
} catch {
if ContinuousClock().now >= deadline {
throw error
}
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
}
@Test("remote A2UI URL is not trusted for native actions")
@MainActor func remoteA2UIURLIsNotTrustedForNativeActions() throws {
let screen = ScreenController()
let trusted = "https://node.ts.net:18789/__openclaw__/a2ui/?platform=ios"
screen.navigate(to: trusted, trustA2UIActions: true)
#expect(screen.isShowingLocalA2UI() == false)
let urls = try [
trusted,
"https://node.ts.net:18789/__openclaw__/a2ui/?platform=ios#step2",
"http://192.168.0.10:18789/__openclaw__/a2ui/?platform=ios",
"https://node.ts.net:18789/__openclaw__/a2ui/?platform=android",
"https://node.ts.net:18789/__openclaw__/canvas/",
"https://evil.ts.net:18789/__openclaw__/a2ui/?platform=ios",
].map { try #require(URL(string: $0)) }
for url in urls {
#expect(screen.isTrustedCanvasUIURL(url) == false)
}
}
@Test("local A2UI URL is trusted for native actions")
@MainActor func localA2UIURLIsTrustedForNativeActions() throws {
let screen = ScreenController()
screen.showLocalA2UI()
let url = try #require(URL(string: screen.urlString))
#expect(url.isFileURL)
#expect(screen.isShowingLocalA2UI() == true)
#expect(screen.isTrustedCanvasUIURL(url) == true)
}
@Test func parseA2UIActionBodyAcceptsJSONString() throws {
let body = ScreenController.parseA2UIActionBody("{\"userAction\":{\"name\":\"hello\"}}")
let userAction = try #require(body?["userAction"] as? [String: Any])
#expect(userAction["name"] as? String == "hello")
}
}

View File

@@ -0,0 +1,99 @@
import Foundation
import Testing
@testable import OpenClaw
private final class ScreenRecordServiceProbe: @unchecked Sendable {
private let lock = NSLock()
private(set) var startCount = 0
private(set) var stopCount = 0
func recordStart() {
self.lock.lock()
self.startCount += 1
self.lock.unlock()
}
func recordStop() {
self.lock.lock()
self.stopCount += 1
self.lock.unlock()
}
func counts() -> (start: Int, stop: Int) {
self.lock.lock()
defer { self.lock.unlock() }
return (self.startCount, self.stopCount)
}
}
@Suite(.serialized) struct ScreenRecordServiceTests {
@Test func `clamp defaults and bounds`() {
#expect(ScreenRecordService._test_clampDurationMs(nil) == 10000)
#expect(ScreenRecordService._test_clampDurationMs(0) == 250)
#expect(ScreenRecordService._test_clampDurationMs(60001) == 60000)
#expect(ScreenRecordService._test_clampFps(nil) == 10)
#expect(ScreenRecordService._test_clampFps(0) == 1)
#expect(ScreenRecordService._test_clampFps(120) == 30)
#expect(ScreenRecordService._test_clampFps(.infinity) == 10)
}
@Test @MainActor func `record rejects invalid screen index`() async {
let recorder = ScreenRecordService()
do {
_ = try await recorder.record(
screenIndex: 1,
durationMs: 250,
fps: 5,
includeAudio: false,
outPath: nil)
Issue.record("Expected invalid screen index to throw")
} catch let error as ScreenRecordService.ScreenRecordError {
#expect(error.localizedDescription.contains("Invalid screen index") == true)
} catch {
Issue.record("Unexpected error type: \(error)")
}
}
@Test func `record stops capture when sleep is cancelled`() async {
let probe = ScreenRecordServiceProbe()
let started = AsyncStream<Void>.makeStream()
let recorder = ScreenRecordService(
startReplayKitCaptureAction: { _, _, completion in
probe.recordStart()
started.continuation.yield()
started.continuation.finish()
completion(nil)
},
stopReplayKitCaptureAction: { completion in
probe.recordStop()
completion(nil)
})
let recordingTask = Task {
try await recorder.record(
screenIndex: nil,
durationMs: 60000,
fps: 5,
includeAudio: false,
outPath: nil)
}
for await _ in started.stream {
break
}
recordingTask.cancel()
do {
_ = try await recordingTask.value
Issue.record("Expected cancellation to throw")
} catch is CancellationError {
// Expected; cleanup should stop ReplayKit before preserving cancellation.
} catch {
Issue.record("Unexpected error type: \(error)")
}
let counts = probe.counts()
#expect(counts.start == 1)
#expect(counts.stop == 1)
}
}

View File

@@ -0,0 +1,32 @@
import Testing
@testable import OpenClaw
@Suite struct SettingsNetworkingHelpersTests {
@Test func diagnosticsIssuesNameEachReviewerVisibleCheck() {
#expect(
SettingsDiagnostics.issues(
gatewayConnected: false,
discoveredGatewayCount: 0,
talkConfigLoaded: false,
notificationsAllowed: false) == [
.gatewayOffline,
.discoveryUnavailable,
.notificationsUnavailable,
])
}
@Test func diagnosticsIssuesRequireTalkConfigOnlyAfterGatewayConnects() {
#expect(
SettingsDiagnostics.issues(
gatewayConnected: true,
discoveredGatewayCount: 1,
talkConfigLoaded: false,
notificationsAllowed: true) == [.talkConfigMissing])
#expect(
SettingsDiagnostics.issueCount(
gatewayConnected: true,
discoveredGatewayCount: 1,
talkConfigLoaded: true,
notificationsAllowed: true) == 0)
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
import OpenClawKit
import Testing
@Suite struct ShareToAgentDeepLinkTests {
@Test func appGroupIdentifierUsesCanonicalOpenClawGroup() {
#expect(OpenClawAppGroup.canonicalIdentifier == "group.ai.openclawfoundation.app.shared")
}
@Test func buildMessageIncludesSharedFields() {
let payload = SharedContentPayload(
title: "Article",
url: URL(string: "https://example.com/post")!,
text: "Read this")
let message = ShareToAgentDeepLink.buildMessage(
from: payload,
instruction: "Summarize and give next steps.")
#expect(message.contains("Shared from iOS."))
#expect(message.contains("Title: Article"))
#expect(message.contains("URL: https://example.com/post"))
#expect(message.contains("Text:\nRead this"))
#expect(message.contains("Summarize and give next steps."))
}
@Test func buildURLEncodesAgentRoute() {
let payload = SharedContentPayload(
title: "",
url: URL(string: "https://example.com")!,
text: nil)
let url = ShareToAgentDeepLink.buildURL(from: payload)
let parsed = url.flatMap { DeepLinkParser.parse($0) }
guard case let .agent(agent)? = parsed else {
Issue.record("Expected openclaw://agent deep link")
return
}
#expect(agent.thinking == "low")
#expect(agent.message.contains("https://example.com"))
}
@Test func buildURLReturnsNilWhenPayloadEmpty() {
ShareToAgentSettings.saveDefaultInstruction(nil)
let payload = SharedContentPayload(title: nil, url: nil, text: nil)
#expect(ShareToAgentDeepLink.buildURL(from: payload) == nil)
}
@Test func shareInstructionSettingsRoundTrip() {
let value = "Focus on booking constraints and alternatives."
ShareToAgentSettings.saveDefaultInstruction(nil)
ShareToAgentSettings.saveDefaultInstruction(value)
defer { ShareToAgentSettings.saveDefaultInstruction(nil) }
#expect(ShareToAgentSettings.loadDefaultInstruction() == value)
}
}

View File

@@ -0,0 +1,349 @@
import OpenClawKit
import SwiftUI
import Testing
import UIKit
@testable import OpenClaw
struct SwiftUIRenderSmokeTests {
@MainActor private static func host(_ view: some View, size: CGSize? = nil) -> UIWindow {
let frame = CGRect(origin: .zero, size: size ?? UIScreen.main.bounds.size)
let window = UIWindow(frame: frame)
window.rootViewController = UIHostingController(rootView: view)
window.makeKeyAndVisible()
window.rootViewController?.view.setNeedsLayout()
window.rootViewController?.view.layoutIfNeeded()
return window
}
@Test @MainActor func `settings pro tab builds A view hierarchy`() {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = SettingsProTab()
.environment(AppAppearanceModel())
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
_ = Self.host(root)
}
@Test @MainActor func `settings pro tab builds in light and dark mode`() {
for scheme in [ColorScheme.light, ColorScheme.dark] {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = SettingsProTab()
.environment(AppAppearanceModel())
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
.preferredColorScheme(scheme)
_ = Self.host(root)
}
}
@Test @MainActor func `settings About destination builds in light and dark mode`() {
for scheme in [ColorScheme.light, ColorScheme.dark] {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = SettingsProTab(directRoute: .about)
.environment(AppAppearanceModel())
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
.preferredColorScheme(scheme)
_ = Self.host(root, size: CGSize(width: 393, height: 852))
}
}
@Test @MainActor func `settings Licenses destination builds in light and dark mode`() {
var windows: [UIWindow] = []
defer { windows.forEach { $0.isHidden = true } }
for scheme in [ColorScheme.light, ColorScheme.dark] {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = SettingsProTab(directRoute: .licenses)
.environment(AppAppearanceModel())
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
.preferredColorScheme(scheme)
windows.append(Self.host(root, size: CGSize(width: 393, height: 852)))
}
}
@Test @MainActor func `settings pro tab appearance row builds for all preferences`() throws {
for preference in AppAppearancePreference.allCases {
let suiteName = "OpenClawTests.appearance.\(preference.rawValue).\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(preference.rawValue, forKey: AppAppearancePreference.storageKey)
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = SettingsProTab()
.defaultAppStorage(defaults)
.environment(AppAppearanceModel(userDefaults: defaults))
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
_ = Self.host(root)
}
}
@Test @MainActor func `hosted push relay disclosure builds A view hierarchy`() {
for typeSize in [DynamicTypeSize.large, .accessibility5] {
let root = HostedPushRelayDisclosureSheet(
message: "Enabling this sends delivery data through OpenClaw's hosted push relay.",
onContinue: {})
.environment(\.dynamicTypeSize, typeSize)
_ = Self.host(root, size: CGSize(width: 402, height: 450))
}
}
@Test @MainActor func `root tabs builds device orientation shell matrix`() {
for scenario in Self.rootTabsShellScenarios() {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs()
.environment(AppAppearanceModel())
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
.environment(\.rootTabsUserInterfaceIdiomOverride, scenario.idiom)
.environment(\.horizontalSizeClass, scenario.horizontalSizeClass)
.environment(\.verticalSizeClass, scenario.verticalSizeClass)
_ = Self.host(root, size: scenario.size)
}
}
@Test @MainActor func `root tabs build gateway state view hierarchies`() {
for appModel in Self.rootTabsGatewayStateModels() {
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs()
.environment(AppAppearanceModel())
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
_ = Self.host(root)
}
}
@Test @MainActor func `gateway trust prompt alert presents when prompt appears after initial render`() async {
let appModel = NodeAppModel()
let gatewayController = Self.gatewayControllerWithCapturedTLSFingerprint(appModel: appModel)
let root = Color.clear
.gatewayTrustPromptAlert()
.environment(gatewayController)
let window = Self.host(root)
await Self.triggerGatewayTrustPrompt(controller: gatewayController)
await Self.waitForPresentedAlert(in: window)
#expect(window.rootViewController?.presentedViewController is UIAlertController)
}
@Test @MainActor func `root prompt alert stack presents gateway trust prompt`() async {
let appModel = NodeAppModel()
let gatewayController = Self.gatewayControllerWithCapturedTLSFingerprint(appModel: appModel)
let root = Color.clear
.gatewayTrustPromptAlert()
.deepLinkAgentPromptAlert()
.environment(appModel)
.environment(gatewayController)
let window = Self.host(root)
await Self.triggerGatewayTrustPrompt(controller: gatewayController)
await Self.waitForPresentedAlert(in: window)
#expect(window.rootViewController?.presentedViewController is UIAlertController)
}
@Test @MainActor func `root prompt alert stack still presents deep link prompt`() async throws {
let appModel = NodeAppModel()
appModel._test_setGatewayConnected(true)
let gatewayController = Self.gatewayControllerWithCapturedTLSFingerprint(appModel: appModel)
let root = Color.clear
.gatewayTrustPromptAlert()
.deepLinkAgentPromptAlert()
.environment(appModel)
.environment(gatewayController)
let window = Self.host(root)
let url = try #require(URL(string: "openclaw://agent?message=hello%20from%20deep%20link"))
await appModel.handleDeepLink(url: url)
await Self.waitForPresentedAlert(in: window)
#expect(window.rootViewController?.presentedViewController is UIAlertController)
}
@MainActor private static func gatewayControllerWithCapturedTLSFingerprint(
appModel: NodeAppModel)
-> GatewayConnectionController
{
GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in .fingerprint("abc123") })
}
@MainActor private static func triggerGatewayTrustPrompt(controller: GatewayConnectionController) async {
let host = "gateway-\(UUID().uuidString).example.com"
let port = 18789
let stableID = "manual|\(host.lowercased())|\(port)"
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
GatewayTLSStore.clearFingerprint(stableID: stableID)
await controller.connectManual(host: host, port: port, useTLS: true)
}
@Test @MainActor func `phone control hub builds gateway state view hierarchies`() {
for appModel in Self.rootTabsGatewayStateModels() {
let root = RootTabsPhoneControlHub(
groups: RootTabs.phoneControlGroups,
initialDestination: nil,
navigationRequest: nil,
openRootDestination: { _ in },
openChatFromControlDetail: { _ in })
.environment(appModel)
_ = Self.host(root)
}
}
@Test @MainActor func `phone control hub builds landscape compact state`() {
let appModel = NodeAppModel()
let root = RootTabsPhoneControlHub(
groups: RootTabs.phoneControlGroups,
initialDestination: nil,
navigationRequest: nil,
openRootDestination: { _ in },
openChatFromControlDetail: { _ in })
.environment(appModel)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
_ = Self.host(root)
}
@Test @MainActor func `routed sidebar screens build offline states`() {
let appModel = NodeAppModel()
let screens: [AnyView] = [
AnyView(CommandCenterTab(openChat: {}, openSettings: {})),
AnyView(IPadActivityScreen(openChat: {}, openSettings: {})),
AnyView(OpenClawDocsScreen()),
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
AnyView(AgentProTab(directRoute: .agents)),
AnyView(AgentProTab(directRoute: .instances)),
AnyView(CommandSessionsScreen(openChat: {})),
AnyView(AgentProTab(directRoute: .dreaming)),
AnyView(AgentProTab(directRoute: .usage)),
AnyView(AgentProTab(directRoute: .cron)),
]
for screen in screens {
let root = NavigationStack { screen }
.environment(appModel)
_ = Self.host(root)
}
}
@Test @MainActor func `task screens build phone landscape compact states`() {
let appModel = NodeAppModel()
let screens: [AnyView] = [
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
]
for screen in screens {
let root = NavigationStack { screen }
.environment(appModel)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
_ = Self.host(root)
}
}
@Test @MainActor func `voice wake words view builds A view hierarchy`() {
let appModel = NodeAppModel()
let root = NavigationStack { VoiceWakeWordsSettingsView() }
.environment(appModel)
_ = Self.host(root)
}
@Test @MainActor func `voice wake toast builds A view hierarchy`() {
let root = VoiceWakeToast(command: "openclaw: do something")
_ = Self.host(root)
}
@MainActor private static func waitForPresentedAlert(in window: UIWindow) async {
for _ in 0..<10 {
if window.rootViewController?.presentedViewController != nil { return }
await Task.yield()
try? await Task.sleep(nanoseconds: 50_000_000)
}
}
@MainActor private static func rootTabsGatewayStateModels() -> [NodeAppModel] {
let offlineModel = NodeAppModel()
let connectingModel = NodeAppModel()
connectingModel.gatewayStatusText = "Connecting..."
let connectedModel = NodeAppModel()
connectedModel.enterAppleReviewDemoMode()
let errorModel = NodeAppModel()
errorModel.gatewayStatusText = "Gateway error: connection refused"
return [offlineModel, connectingModel, connectedModel, errorModel]
}
private static func rootTabsShellScenarios() -> [RootTabsShellScenario] {
[
RootTabsShellScenario(
idiom: .phone,
size: CGSize(width: 393, height: 852),
horizontalSizeClass: .compact,
verticalSizeClass: .regular),
RootTabsShellScenario(
idiom: .phone,
size: CGSize(width: 852, height: 393),
horizontalSizeClass: .regular,
verticalSizeClass: .compact),
RootTabsShellScenario(
idiom: .pad,
size: CGSize(width: 1024, height: 1366),
horizontalSizeClass: .regular,
verticalSizeClass: .regular),
RootTabsShellScenario(
idiom: .pad,
size: CGSize(width: 1366, height: 1024),
horizontalSizeClass: .regular,
verticalSizeClass: .regular),
]
}
private struct RootTabsShellScenario {
let idiom: UIUserInterfaceIdiom
let size: CGSize
let horizontalSizeClass: UserInterfaceSizeClass
let verticalSizeClass: UserInterfaceSizeClass
}
}

View File

@@ -0,0 +1,422 @@
import AVFoundation
import Foundation
import OpenClawKit
import OpenClawProtocol
import Testing
@testable import OpenClaw
@MainActor
private final class RecordingGatewaySpeechSynthesizer: TalkGatewaySpeechSynthesizing {
let audio: TalkGatewaySpeechAudio
private(set) var requests: [TalkGatewaySpeechRequest] = []
init(audio: TalkGatewaySpeechAudio) {
self.audio = audio
}
func synthesize(_ request: TalkGatewaySpeechRequest) async throws -> TalkGatewaySpeechAudio {
self.requests.append(request)
return self.audio
}
}
@MainActor
private final class SuspendedGatewaySpeechSynthesizer: TalkGatewaySpeechSynthesizing {
private var continuation: CheckedContinuation<TalkGatewaySpeechAudio, Never>?
var hasPendingRequest: Bool {
self.continuation != nil
}
func synthesize(_: TalkGatewaySpeechRequest) async throws -> TalkGatewaySpeechAudio {
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
func complete() {
self.continuation?.resume(returning: TalkGatewaySpeechAudio(
data: Data([1, 2, 3]),
provider: "xiaomi",
outputFormat: "mp3"))
self.continuation = nil
}
}
@MainActor
private final class RecordingBufferedAudioPlayer: TalkBufferedAudioPlaying {
private(set) var payloads: [Data] = []
func play(data: Data) async -> StreamingPlaybackResult {
self.payloads.append(data)
return StreamingPlaybackResult(finished: true, interruptedAt: nil)
}
func stop() -> Double? {
nil
}
}
@MainActor
private final class InterruptibleBufferedAudioPlayer: TalkBufferedAudioPlaying {
private var continuation: CheckedContinuation<StreamingPlaybackResult, Never>?
var isPlaying: Bool {
self.continuation != nil
}
func play(data _: Data) async -> StreamingPlaybackResult {
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
func stop() -> Double? {
self.continuation?.resume(returning: StreamingPlaybackResult(
finished: false,
interruptedAt: 0))
self.continuation = nil
return 0
}
}
@MainActor
struct TalkGatewaySpeechClientTests {
@Test func `forwards talk directives and decodes audio`() async throws {
let expectedAudio = Data([1, 2, 3])
var requestedMethod: String?
var requestedParams: TalkSpeakParams?
var requestedTimeout: Int?
let client = TalkGatewaySpeechClient { method, paramsJSON, timeoutSeconds in
requestedMethod = method
requestedTimeout = timeoutSeconds
requestedParams = try JSONDecoder().decode(
TalkSpeakParams.self,
from: Data((paramsJSON ?? "").utf8))
return try JSONEncoder().encode(TalkSpeakResult(
audiobase64: expectedAudio.base64EncodedString(),
provider: "xiaomi",
outputformat: "mp3",
voicecompatible: false,
mimetype: "audio/mpeg",
fileextension: ".mp3"))
}
let directive = TalkDirective(
voiceId: "mimo_default",
modelId: "mimo-v2.5-tts",
speed: 1.1,
language: "zh-CN",
outputFormat: "mp3")
let audio = try await client.synthesize(TalkGatewaySpeechRequest(
text: "hello",
voiceId: "resolved-voice",
modelId: "resolved-model",
outputFormat: "mp3",
directive: directive))
#expect(requestedMethod == "talk.speak")
#expect(requestedTimeout == 125)
#expect(requestedParams?.text == "hello")
#expect(requestedParams?.voiceid == "resolved-voice")
#expect(requestedParams?.modelid == "resolved-model")
#expect(requestedParams?.speed == 1.1)
#expect(requestedParams?.language == "zh-CN")
#expect(requestedParams?.outputformat == "mp3")
#expect(audio.data == expectedAudio)
#expect(audio.provider == "xiaomi")
#expect(audio.playbackMode == .buffered)
}
@Test func `resolves gateway audio playback metadata`() {
let pcm = TalkGatewaySpeechAudio(
data: Data([0, 1]),
provider: "elevenlabs",
outputFormat: "pcm_24000")
let mimeOnlyMP3 = TalkGatewaySpeechAudio(
data: Data([0, 1]),
provider: "xiaomi",
outputFormat: nil)
let wav = TalkGatewaySpeechAudio(
data: Data([0, 1]),
provider: "xiaomi",
outputFormat: "wav")
let rawPCM = TalkGatewaySpeechAudio(
data: Data([0, 1]),
provider: "xai",
outputFormat: "pcm")
let microsoftRawPCM = TalkGatewaySpeechAudio(
data: Data([0, 1]),
provider: "microsoft",
outputFormat: "raw-24khz-16bit-mono-pcm")
let rawULaw = TalkGatewaySpeechAudio(
data: Data([0, 1]),
provider: "microsoft",
outputFormat: "ulaw_8000")
let microsoftRIFF = TalkGatewaySpeechAudio(
data: Data([0, 1]),
provider: "microsoft",
outputFormat: "riff-24khz-16bit-mono-pcm")
#expect(pcm.playbackMode == .pcm(sampleRate: 24000))
#expect(mimeOnlyMP3.playbackMode == .buffered)
#expect(wav.playbackMode == .buffered)
#expect(rawPCM.playbackMode == .unsupportedRaw(codec: "pcm"))
#expect(microsoftRawPCM.playbackMode == .unsupportedRaw(codec: "raw-24khz-16bit-mono-pcm"))
#expect(rawULaw.playbackMode == .unsupportedRaw(codec: "ulaw_8000"))
#expect(microsoftRIFF.playbackMode == .buffered)
}
@Test func `gateway speech provider stays native and uses talk speak`() async {
let parsed = Self.parseSpeechProvider("xiaomi")
let routing = TalkModeRoutingResolver.resolve(
parsed: parsed,
providerSelection: .gatewayDefault,
defaultProvider: "elevenlabs",
defaultRealtimeModelId: "gpt-realtime-2")
#expect(routing.activeProvider == "xiaomi")
#expect(routing.executionMode == .native)
#expect(routing.route == .gatewayTalkSpeak)
let expectedAudio = Data([4, 5, 6])
let synthesizer = RecordingGatewaySpeechSynthesizer(audio: TalkGatewaySpeechAudio(
data: expectedAudio,
provider: "xiaomi",
outputFormat: "mp3"))
let audioPlayer = RecordingBufferedAudioPlayer()
let manager = TalkModeManager(
allowSimulatorCapture: true,
gatewaySpeechSynthesizer: synthesizer)
manager.bufferedPlayer = audioPlayer
manager._test_applyLoadedTalkConfig(parsed, providerSelection: .gatewayDefault)
#expect(manager._test_runtimeRoute() == .gatewayTalkSpeak)
#expect(manager._test_executionMode() == .native)
#expect(!manager.gatewayTalkUsesRealtime)
#expect(manager.gatewayTalkTransportLabel == "Native")
await manager._test_playAssistant(text: "Gateway voice")
#expect(synthesizer.requests.map(\.text) == ["Gateway voice"])
#expect(audioPlayer.payloads == [expectedAudio])
}
@Test func `persisted voice and model overrides reach later gateway requests`() async {
let parsed = Self.parseSpeechProvider("xiaomi")
let synthesizer = RecordingGatewaySpeechSynthesizer(audio: TalkGatewaySpeechAudio(
data: Data([4, 5, 6]),
provider: "xiaomi",
outputFormat: "mp3"))
let manager = TalkModeManager(
allowSimulatorCapture: true,
gatewaySpeechSynthesizer: synthesizer)
manager.bufferedPlayer = RecordingBufferedAudioPlayer()
manager._test_applyLoadedTalkConfig(parsed, providerSelection: .gatewayDefault)
await manager._test_playAssistant(text: "{\"voice\":\"alloy\",\"model\":\"expressive\"}\nFirst")
await manager._test_playAssistant(text: "Second")
#expect(synthesizer.requests.count == 2)
#expect(synthesizer.requests[1].voiceId == "alloy")
#expect(synthesizer.requests[1].modelId == "expressive")
}
@Test func `omitted gateway model does not send eleven labs fallback`() async {
let parsed = Self.parseSpeechProvider("openai", model: nil)
let synthesizer = RecordingGatewaySpeechSynthesizer(audio: TalkGatewaySpeechAudio(
data: Data([4, 5, 6]),
provider: "openai",
outputFormat: "mp3"))
let manager = TalkModeManager(
allowSimulatorCapture: true,
gatewaySpeechSynthesizer: synthesizer)
manager.bufferedPlayer = RecordingBufferedAudioPlayer()
manager._test_applyLoadedTalkConfig(parsed, providerSelection: .gatewayDefault)
await manager._test_playAssistant(text: "No model override")
#expect(synthesizer.requests.count == 1)
#expect(synthesizer.requests[0].modelId == nil)
#expect(manager.gatewayTalkDefaultModelId == nil)
}
@Test func `stopped talk does not play completed gateway synthesis`() async {
let parsed = Self.parseSpeechProvider("xiaomi")
let synthesizer = SuspendedGatewaySpeechSynthesizer()
let audioPlayer = RecordingBufferedAudioPlayer()
let manager = TalkModeManager(
allowSimulatorCapture: true,
gatewaySpeechSynthesizer: synthesizer)
manager.bufferedPlayer = audioPlayer
manager._test_applyLoadedTalkConfig(parsed, providerSelection: .gatewayDefault)
let playback = Task { await manager._test_playAssistant(text: "Delayed voice") }
while !synthesizer.hasPendingRequest {
await Task.yield()
}
manager.stop()
synthesizer.complete()
await playback.value
#expect(audioPlayer.payloads.isEmpty)
}
@Test func `stale buffered player callback does not finish replacement`() async throws {
let player = TalkBufferedAudioPlayer()
let wav = makeWav16Mono(sampleRate: 8000, samples: 8000)
let stalePlayer = try AVAudioPlayer(data: wav)
let stopAfterStaleCallback = Task { @MainActor in
player.audioPlayerDidFinishPlaying(stalePlayer, successfully: true)
return player.stop()
}
let result = await player.play(data: wav)
let interruptedAt = await stopAfterStaleCallback.value
#expect(interruptedAt != nil)
#expect(!result.finished)
}
@Test func `interrupted gateway playback stops speech recognition`() async {
let parsed = Self.parseSpeechProvider("xiaomi", interruptOnSpeech: true)
let synthesizer = RecordingGatewaySpeechSynthesizer(audio: TalkGatewaySpeechAudio(
data: Data([4, 5, 6]),
provider: "xiaomi",
outputFormat: "mp3"))
let audioPlayer = InterruptibleBufferedAudioPlayer()
let manager = TalkModeManager(
allowSimulatorCapture: true,
gatewaySpeechSynthesizer: synthesizer)
manager.bufferedPlayer = audioPlayer
manager._test_applyLoadedTalkConfig(parsed, providerSelection: .gatewayDefault)
let playback = Task { await manager._test_playAssistant(text: "Interrupt me") }
while !audioPlayer.isPlaying {
await Task.yield()
}
#expect(manager._test_hasRecognitionRequest())
manager._test_stopSpeaking(storeInterruption: false)
await playback.value
#expect(!manager._test_hasRecognitionRequest())
#expect(manager._test_lastInterruptedAtSeconds() == nil)
}
@Test func `open AI speech provider without realtime config uses talk speak`() {
let parsed = Self.parseSpeechProvider("openai")
let routing = TalkModeRoutingResolver.resolve(
parsed: parsed,
providerSelection: .gatewayDefault,
defaultProvider: "elevenlabs",
defaultRealtimeModelId: "gpt-realtime-2")
#expect(routing.activeProvider == "openai")
#expect(routing.executionMode == .native)
#expect(routing.route == .gatewayTalkSpeak)
}
@Test func `explicit realtime config keeps realtime relay`() {
let parsed = TalkModeGatewayConfigParser.parse(
config: [
"talk": [
"provider": "openai",
"providers": [
"openai": [
"mode": "realtime",
"model": "gpt-realtime-2",
],
],
"resolved": [
"provider": "openai",
"config": [
"model": "gpt-realtime-2",
],
],
"realtime": [
"provider": "openai",
"mode": "realtime",
"transport": "gateway-relay",
"model": "gpt-realtime-2",
],
],
],
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
let routing = TalkModeRoutingResolver.resolve(
parsed: parsed,
providerSelection: .gatewayDefault,
defaultProvider: "elevenlabs",
defaultRealtimeModelId: "gpt-realtime-2")
#expect(routing.executionMode == .realtimeRelay)
#expect(routing.route == .realtimeRelay)
#expect(!routing.route.usesGatewayTalkSpeak)
#expect(routing.route.gatewayOwnsCredentials)
}
private static func parseSpeechProvider(
_ provider: String,
model: String? = "speech-model",
interruptOnSpeech: Bool = false) -> TalkModeGatewayConfigState
{
let providerConfig: [String: String] = model.map { ["model": $0] } ?? [:]
return TalkModeGatewayConfigParser.parse(
config: [
"talk": [
"provider": provider,
"providers": [provider: providerConfig],
"resolved": [
"provider": provider,
"config": providerConfig,
],
"interruptOnSpeech": interruptOnSpeech,
],
],
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
}
}
private func makeWav16Mono(sampleRate: UInt32, samples: Int) -> Data {
let channels: UInt16 = 1
let bitsPerSample: UInt16 = 16
let blockAlign = channels * (bitsPerSample / 8)
let byteRate = sampleRate * UInt32(blockAlign)
let dataSize = UInt32(samples) * UInt32(blockAlign)
var data = Data()
data.append(contentsOf: [0x52, 0x49, 0x46, 0x46])
data.appendTestLEUInt32(36 + dataSize)
data.append(contentsOf: [0x57, 0x41, 0x56, 0x45])
data.append(contentsOf: [0x66, 0x6D, 0x74, 0x20])
data.appendTestLEUInt32(16)
data.appendTestLEUInt16(1)
data.appendTestLEUInt16(channels)
data.appendTestLEUInt32(sampleRate)
data.appendTestLEUInt32(byteRate)
data.appendTestLEUInt16(blockAlign)
data.appendTestLEUInt16(bitsPerSample)
data.append(contentsOf: [0x64, 0x61, 0x74, 0x61])
data.appendTestLEUInt32(dataSize)
data.append(Data(repeating: 0, count: Int(dataSize)))
return data
}
extension Data {
fileprivate mutating func appendTestLEUInt16(_ value: UInt16) {
var value = value.littleEndian
Swift.withUnsafeBytes(of: &value) { self.append(contentsOf: $0) }
}
fileprivate mutating func appendTestLEUInt32(_ value: UInt32) {
var value = value.littleEndian
Swift.withUnsafeBytes(of: &value) { self.append(contentsOf: $0) }
}
}

View File

@@ -0,0 +1,846 @@
import AVFoundation
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
@MainActor
struct TalkModeManagerTests {
@Test func `recognizes open AI maximum duration errors as terminal`() throws {
let event = try JSONDecoder().decode(
TalkRealtimeServerEvent.self,
from: Data(#"{"type":"error","error":{"message":"Your session hit the maximum duration of 60 minutes."}}"#
.utf8))
#expect(event.isMaximumDurationError)
}
@Test func `keeps recoverable open AI errors in the current session`() throws {
let event = try JSONDecoder().decode(
TalkRealtimeServerEvent.self,
from: Data(#"{"type":"error","error":{"message":"Cancellation failed: no active response found"}}"#.utf8))
#expect(!event.isMaximumDurationError)
}
@Test func `parses open AI realtime provider model and voice`() {
let config: [String: Any] = [
"talk": [
"provider": "elevenlabs",
"providers": [
"elevenlabs": [
"modelId": "eleven_v3",
"voiceId": "eleven-voice",
],
],
"resolved": [
"provider": "elevenlabs",
"config": [
"modelId": "eleven_v3",
"voiceId": "eleven-voice",
],
],
"realtime": [
"provider": " openai ",
"model": " gpt-realtime-2 ",
"voice": " marin ",
"mode": "realtime",
"transport": "gateway-relay",
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
#expect(parsed.activeProvider == "elevenlabs")
#expect(parsed.executionMode == .realtimeRelay)
#expect(parsed.defaultModelId == "eleven_v3")
#expect(parsed.defaultVoiceId == "eleven-voice")
#expect(parsed.realtimeProvider == "openai")
#expect(parsed.realtimeModelId == "gpt-realtime-2")
#expect(parsed.realtimeVoiceId == "marin")
}
@Test func `infers realtime provider when provider map has single entry`() {
let config: [String: Any] = [
"talk": [
"realtime": [
"mode": "realtime",
"transport": "webrtc",
"providers": [
"openai": [
"model": "gpt-realtime-2",
],
],
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
#expect(parsed.executionMode == .realtimeWebRTC)
#expect(parsed.realtimeProvider == "openai")
#expect(parsed.realtimeModelId == "gpt-realtime-2")
}
@Test func `formats generic realtime voice mode without native provider fallback`() {
let descriptor = TalkVoiceModeDescriptorBuilder.build(
providerId: "realtime",
providerLabel: "Realtime Voice",
modelId: "gpt-realtime-2",
voiceId: nil,
transport: "webrtc",
isRealtime: true)
#expect(descriptor.title == "Realtime Voice")
#expect(descriptor.subtitle == "Native WebRTC • gpt-realtime-2")
}
@Test func `defaults open AI realtime model when provider omits model`() {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "openai",
"mode": "realtime",
"transport": "gateway-relay",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
#expect(parsed.executionMode == .realtimeRelay)
#expect(parsed.defaultModelId == "eleven_v3")
#expect(parsed.realtimeModelId == "gpt-realtime-2")
#expect(parsed.realtimeVoiceId == nil)
}
@Test func `resolves realtime voice picker overrides`() {
#expect(TalkModeRealtimeVoiceSelection.resolvedOverride(nil) == nil)
#expect(TalkModeRealtimeVoiceSelection.resolvedOverride("") == nil)
#expect(TalkModeRealtimeVoiceSelection.resolvedOverride(" Cedar ") == "cedar")
#expect(TalkModeRealtimeVoiceSelection.resolvedOverride("unknown") == nil)
}
@Test func `formats open AI realtime voice mode`() {
let descriptor = TalkVoiceModeDescriptorBuilder.build(
providerId: "openai",
providerLabel: "OpenAI",
modelId: "gpt-realtime-2",
voiceId: "marin",
transport: "webrtc",
isRealtime: true)
#expect(descriptor.title == "GPT Realtime 2.0")
#expect(descriptor.subtitle == "Native WebRTC • Marin")
#expect(descriptor.accessibilityValue == "GPT Realtime 2.0, Native WebRTC • Marin")
}
@Test func `formats gateway relay realtime voice mode`() {
let descriptor = TalkVoiceModeDescriptorBuilder.build(
providerId: "google",
providerLabel: "Google Live Voice",
modelId: "gemini-live-2.5-flash-preview",
voiceId: nil,
transport: "gateway-relay",
isRealtime: true)
#expect(descriptor.title == "Google Live Voice")
#expect(descriptor.subtitle == "Gateway Relay • gemini-live-2.5-flash-preview")
}
@Test func `formats eleven labs voice mode`() {
let descriptor = TalkVoiceModeDescriptorBuilder.build(
providerId: "elevenlabs",
providerLabel: "ElevenLabs",
modelId: "eleven_v3",
voiceId: "voice-id",
transport: "native",
isRealtime: false)
#expect(descriptor.title == "ElevenLabs")
#expect(descriptor.subtitle == "Native • eleven_v3 • voice-id")
}
@Test func `formats system voice fallback mode`() {
let descriptor = TalkVoiceModeDescriptorBuilder.build(
providerId: "system",
providerLabel: "iOS System Voice",
modelId: nil,
voiceId: "en-US",
transport: "native",
isRealtime: false)
#expect(descriptor.title == "iOS System Voice")
#expect(descriptor.subtitle == "Native • en-US")
}
@Test func `open AI realtime selection defaults to native web RTC`() {
let manager = TalkModeManager(allowSimulatorCapture: true)
manager._test_applyOpenAIRealtimeSelectionDefaults()
#expect(manager._test_executionMode() == .realtimeWebRTC)
#expect(manager._test_realtimeProvider() == "openai")
#expect(manager._test_realtimeModelId() == "gpt-realtime-2")
#expect(!manager._test_gatewayTalkUsesRealtimeRelay())
}
@Test func `open AI realtime selection clears stale realtime config`() {
let manager = TalkModeManager(allowSimulatorCapture: true)
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "google",
"model": "gemini-live-2.5-flash-preview",
"voice": "puck",
"mode": "realtime",
"transport": "gateway-relay",
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
manager._test_applyLoadedTalkConfig(parsed, providerSelection: .gatewayDefault)
manager._test_applyOpenAIRealtimeSelectionDefaults()
#expect(manager._test_executionMode() == .realtimeWebRTC)
#expect(manager._test_realtimeProvider() == "openai")
#expect(manager._test_realtimeModelId() == "gpt-realtime-2")
#expect(manager.gatewayTalkRealtimeVoiceId == nil)
#expect(!manager._test_gatewayTalkUsesRealtimeRelay())
}
@Test func `open AI realtime selection keeps explicit open AI voice override`() {
let manager = TalkModeManager(allowSimulatorCapture: true)
let defaults = UserDefaults.standard
defaults.set(" Cedar ", forKey: TalkModeRealtimeVoiceSelection.storageKey)
defer { defaults.removeObject(forKey: TalkModeRealtimeVoiceSelection.storageKey) }
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "google",
"model": "gemini-live-2.5-flash-preview",
"voice": "puck",
"mode": "realtime",
"transport": "gateway-relay",
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
manager._test_applyLoadedTalkConfig(parsed, providerSelection: .openAIRealtime)
#expect(manager._test_realtimeProvider() == "openai")
#expect(manager._test_realtimeModelId() == "gpt-realtime-2")
#expect(manager.gatewayTalkRealtimeVoiceId == "cedar")
}
@Test func `open AI selection preserves configured voice for case insensitive provider`() {
let manager = TalkModeManager(allowSimulatorCapture: true)
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": " OpenAI ",
"voice": "marin",
"mode": "realtime",
"transport": "webrtc",
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
manager._test_applyLoadedTalkConfig(parsed, providerSelection: .openAIRealtime)
#expect(manager._test_realtimeProvider() == "openai")
#expect(manager.gatewayTalkRealtimeVoiceId == "marin")
}
@Test func `builds generic realtime fallback issue for display`() {
let issue = TalkRuntimeIssue.realtimeUnavailable(
message: "OpenAI API key rejected with 401",
provider: "openai",
model: "gpt-realtime-2",
transport: "gateway-relay",
phase: "start")
#expect(issue.code == .realtimeUnavailable)
#expect(issue.displayMessage == "OpenAI API key rejected with 401")
#expect(issue.diagnosticSummary.contains("provider: openai"))
#expect(issue.diagnosticSummary.contains("model: gpt-realtime-2"))
#expect(issue.fallbackStatusText == "Listening (iOS Speech fallback)")
#expect(issue.fallbackBannerTitle == "Using iOS Speech fallback")
#expect(issue.fallbackBannerOwnerLabel == "Fallback active")
#expect(issue
.fallbackBannerMessage ==
"Realtime voice did not start. Talk is running with iOS speech recognition and TTS.")
#expect(issue.technicalDetails.contains("code: realtime_unavailable"))
}
@Test func `native fallback keeps realtime issue visible`() {
let manager = TalkModeManager(allowSimulatorCapture: true)
let issue = TalkRuntimeIssue(
code: .realtimeUnavailable,
message: "Realtime closed before it became ready.",
provider: "openai",
model: "gpt-realtime-2",
transport: "gateway-relay",
phase: "connect")
manager._test_markNativeFallbackActive(after: issue)
#expect(manager.statusText == "Listening (iOS Speech fallback)")
#expect(manager._test_gatewayTalkActiveModeTitle() == "iOS Speech fallback")
#expect(manager._test_gatewayTalkActiveModeSubtitle() == "Realtime closed before it became ready.")
#expect(manager._test_gatewayTalkLastIssueText()?.contains("phase: connect") == true)
#expect(manager._test_gatewayTalkCurrentFallbackIssue() == issue)
}
@Test func `gateway talk issue details drive realtime failure display`() {
let manager = TalkModeManager(allowSimulatorCapture: true)
let error = GatewayResponseError(
method: "talk.session.create",
code: "UNAVAILABLE",
message: "Error: OpenAI API key rejected with 401",
details: [
"talkIssue": AnyCodable([
"code": "realtime_unavailable",
"message": "OpenAI API key rejected with 401",
"provider": "openai",
"model": "gpt-realtime-2",
"transport": "gateway-relay",
"phase": "request",
]),
])
let issue = manager._test_realtimeIssue(from: error, phase: "start")
#expect(issue.code == .realtimeUnavailable)
#expect(issue.displayMessage == "OpenAI API key rejected with 401")
#expect(issue.provider == "openai")
#expect(issue.model == "gpt-realtime-2")
#expect(issue.transport == "gateway-relay")
#expect(issue.phase == "request")
}
@Test func `relay startup issue survives until ready status`() {
let manager = TalkModeManager(allowSimulatorCapture: true)
let issue = TalkRuntimeIssue(
code: .realtimeUnavailable,
message: "OpenAI API key rejected with 401",
provider: "openai",
model: "gpt-realtime-2",
transport: "gateway-relay",
phase: "connect")
manager._test_recordRealtimeIssue(issue)
manager._test_handleRealtimeRelayStatus("Connecting realtime…")
#expect(manager._test_gatewayTalkActiveModeTitle() == "Realtime unavailable")
#expect(manager._test_gatewayTalkLastIssueText()?.contains("OpenAI API key rejected") == true)
manager._test_handleRealtimeRelayStatus("Listening (Realtime)")
#expect(manager.statusText == "Listening (Realtime)")
#expect(manager._test_gatewayTalkLastIssueText() == nil)
#expect(manager._test_gatewayTalkCurrentFallbackIssue() == nil)
}
@Test func `relay close clears active realtime mode`() {
let manager = TalkModeManager(allowSimulatorCapture: true)
manager._test_handleRealtimeRelayStatus("Listening (Realtime)")
#expect(manager.statusText == "Listening (Realtime)")
#expect(manager._test_gatewayTalkActiveModeTitle() != "Not active")
manager._test_handleRealtimeRelayStatus("Ready")
#expect(manager.statusText == "Ready")
#expect(manager._test_gatewayTalkActiveModeTitle() == "Not active")
#expect(manager._test_gatewayTalkActiveModeSubtitle() == nil)
}
@Test func `relay close restarts enabled continuous realtime`() {
let manager = TalkModeManager(allowSimulatorCapture: true)
manager._test_prepareEnabledRealtimeSessionForClose()
manager._test_handleRealtimeRelayStatus("Listening (Realtime)")
manager._test_handleRealtimeRelayStatus("Ready")
#expect(manager.statusText == "Reconnecting")
#expect(manager._test_rapidRealtimeRestartCount() == 1)
manager.isEnabled = false
}
@Test func `recurring realtime ready status preserves push to talk capture`() {
let manager = TalkModeManager(allowSimulatorCapture: true)
#expect(manager._test_realtimeStatusPreservesPushToTalkCapture())
}
@Test func `relay retry clears stale fallback trigger but keeps last issue visible`() {
let manager = TalkModeManager(allowSimulatorCapture: true)
let issue = TalkRuntimeIssue(
code: .realtimeUnavailable,
message: "Realtime closed before it became ready.",
provider: "openai",
model: "gpt-realtime-2",
transport: "gateway-relay",
phase: "connect")
manager._test_recordRealtimeIssue(issue)
manager._test_markNativeFallbackActive(after: issue)
#expect(manager._test_hasPendingRealtimeIssue())
#expect(manager._test_gatewayTalkCurrentFallbackIssue() == issue)
manager._test_prepareRealtimeRelayStart()
#expect(!manager._test_hasPendingRealtimeIssue())
#expect(manager._test_gatewayTalkCurrentFallbackIssue() == nil)
#expect(manager._test_gatewayTalkLastIssueText()?.contains("Realtime closed before") == true)
}
@Test func `maps web RTC realtime transport to native web RTC on IOS`() {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "openai",
"mode": "realtime",
"transport": "webrtc",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
#expect(parsed.executionMode == .realtimeWebRTC)
}
@Test func `keeps Azure open AI realtime on gateway relay`() {
for providerConfig in [
["azureEndpoint": "https://example.openai.azure.com"],
["azureDeployment": "realtime-prod"],
] {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "openai",
"providers": ["openai": providerConfig],
"mode": "realtime",
"transport": "webrtc",
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
let routing = TalkModeRoutingResolver.resolve(
parsed: parsed,
providerSelection: .openAIRealtime,
defaultProvider: "elevenlabs",
defaultRealtimeModelId: "gpt-realtime-2")
#expect(parsed.executionMode == .realtimeRelay)
#expect(routing.route == .realtimeRelay)
}
}
@Test func `open AI selection keeps its Azure config on gateway relay`() {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "google",
"providers": [
"google": ["model": "gemini-live"],
"OpenAI": ["azureDeployment": "realtime-prod"],
],
"mode": "realtime",
"transport": "webrtc",
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
let routing = TalkModeRoutingResolver.resolve(
parsed: parsed,
providerSelection: .openAIRealtime,
defaultProvider: "elevenlabs",
defaultRealtimeModelId: "gpt-realtime-2")
#expect(parsed.realtimeProvider == "google")
#expect(routing.route == .realtimeRelay)
}
@Test func `restarts an enabled continuous realtime session after provider close`() {
#expect(TalkModeManager._test_shouldRestartRealtimeSession(
isEnabled: true,
gatewayConnected: true,
captureIsContinuous: true))
#expect(!TalkModeManager._test_shouldRestartRealtimeSession(
isEnabled: false,
gatewayConnected: true,
captureIsContinuous: true))
#expect(!TalkModeManager._test_shouldRestartRealtimeSession(
isEnabled: true,
gatewayConnected: false,
captureIsContinuous: true))
#expect(!TalkModeManager._test_shouldRestartRealtimeSession(
isEnabled: true,
gatewayConnected: true,
captureIsContinuous: false))
#expect(TalkModeManager._test_realtimeRestartAttempt(
previousRapidRestarts: 1,
activeDuration: 5) == 2)
#expect(TalkModeManager._test_realtimeRestartAttempt(
previousRapidRestarts: 2,
activeDuration: 31) == 1)
#expect(TalkModeManager._test_realtimeRestartDelayNanoseconds(attempt: 1) == 500_000_000)
#expect(TalkModeManager._test_realtimeRestartDelayNanoseconds(attempt: 2) == 2_000_000_000)
#expect(TalkModeManager._test_realtimeRestartDelayNanoseconds(attempt: 3) == nil)
}
@Test func `keeps provider web socket realtime transport on gateway relay`() {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "google",
"mode": "realtime",
"transport": "provider-websocket",
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
#expect(parsed.executionMode == .realtimeRelay)
}
@Test func `leaves native mode for unsupported realtime brain`() {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "google",
"mode": "realtime",
"transport": "gateway-relay",
"brain": "direct-tools",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
#expect(parsed.executionMode == .native)
}
@Test func `keeps non open AI realtime default transport on gateway relay`() {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "google",
"mode": "realtime",
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
#expect(parsed.executionMode == .realtimeRelay)
}
@Test func `keeps non open AI web RTC transport on gateway relay`() {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "google",
"model": "gemini-live-2.5-flash-preview",
"mode": "realtime",
"transport": "webrtc",
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
#expect(parsed.executionMode == .realtimeRelay)
}
@Test func `open AI selection overrides non open AI web RTC provider`() {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "google",
"mode": "realtime",
"transport": "webrtc",
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
let routing = TalkModeRoutingResolver.resolve(
parsed: parsed,
providerSelection: .openAIRealtime,
defaultProvider: "elevenlabs",
defaultRealtimeModelId: "gpt-realtime-2")
#expect(routing.activeProvider == "openai")
#expect(routing.realtimeProvider == "openai")
#expect(routing.realtimeModelId == "gpt-realtime-2")
#expect(routing.executionMode == .realtimeWebRTC)
#expect(routing.route == .realtimeWebRTC)
}
@Test func `open AI selection preserves explicit gateway owned transport`() {
for transport in ["gateway-relay", "provider-websocket"] {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "google",
"mode": "realtime",
"transport": transport,
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
let routing = TalkModeRoutingResolver.resolve(
parsed: parsed,
providerSelection: .openAIRealtime,
defaultProvider: "elevenlabs",
defaultRealtimeModelId: "gpt-realtime-2")
#expect(routing.realtimeProvider == "openai")
#expect(routing.executionMode == .realtimeRelay)
#expect(routing.route == .realtimeRelay)
}
}
@Test func `speaker preference preserves external audio routes`() {
let externalRouteOptions = TalkAudioRoute.categoryOptions(speakerphoneEnabled: false)
#expect(externalRouteOptions.contains(.allowBluetoothHFP))
#expect(externalRouteOptions.contains(.allowBluetoothA2DP))
#expect(externalRouteOptions.contains(.allowAirPlay))
#expect(!externalRouteOptions.contains(.defaultToSpeaker))
#expect(TalkAudioRoute.categoryOptions(speakerphoneEnabled: true).contains(.defaultToSpeaker))
#expect(TalkAudioRoute.shouldForceSpeaker(
preferenceEnabled: true,
outputPortTypes: [.builtInReceiver]))
#expect(TalkAudioRoute.shouldForceSpeaker(
preferenceEnabled: true,
outputPortTypes: [.builtInSpeaker]))
#expect(!TalkAudioRoute.shouldForceSpeaker(
preferenceEnabled: false,
outputPortTypes: [.builtInReceiver]))
#expect(!TalkAudioRoute.shouldForceSpeaker(
preferenceEnabled: true,
outputPortTypes: []))
let externalOutputs: [AVAudioSession.Port] = [
.airPlay,
.bluetoothA2DP,
.bluetoothHFP,
.bluetoothLE,
.carAudio,
.headphones,
.HDMI,
.lineOut,
.usbAudio,
]
for output in externalOutputs {
#expect(!TalkAudioRoute.shouldForceSpeaker(
preferenceEnabled: true,
outputPortTypes: [output]))
}
}
@Test func `maps open AI realtime default transport to native web RTC`() {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "openai",
"mode": "realtime",
"brain": "agent-consult",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
#expect(parsed.executionMode == .realtimeWebRTC)
}
@Test func `parses redacted gateway realtime config`() {
let config: [String: Any] = [
"talk": [
"providers": [
"elevenlabs": [
"apiKey": "__OPENCLAW_REDACTED__",
"voiceId": "bIHbv24MWmeRgasZH58o",
],
],
"realtime": [
"provider": "openai",
"providers": [
"openai": [
"model": "gpt-realtime-2",
"voice": "cedar",
],
],
"model": "gpt-realtime-2",
"mode": "realtime",
"transport": "webrtc",
"brain": "agent-consult",
],
"provider": "elevenlabs",
"resolved": [
"provider": "elevenlabs",
"config": [
"apiKey": "__OPENCLAW_REDACTED__",
"voiceId": "bIHbv24MWmeRgasZH58o",
],
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
#expect(parsed.activeProvider == "elevenlabs")
#expect(parsed.executionMode == .realtimeWebRTC)
#expect(parsed.realtimeProvider == "openai")
#expect(parsed.realtimeModelId == "gpt-realtime-2")
#expect(parsed.realtimeVoiceId == "cedar")
#expect(parsed.rawConfigApiKey == "__OPENCLAW_REDACTED__")
}
@Test func `leaves native mode for managed room realtime transport`() {
let config: [String: Any] = [
"talk": [
"realtime": [
"provider": "openai",
"mode": "realtime",
"transport": "managed-room",
],
],
]
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultRealtimeModelIdFallback: "gpt-realtime-2",
defaultSilenceTimeoutMs: 900)
#expect(parsed.executionMode == .native)
}
@Test func `detects PCM format rejection from eleven labs error`() {
let error = NSError(
domain: "ElevenLabsTTS",
code: 403,
userInfo: [
NSLocalizedDescriptionKey: "ElevenLabs failed: 403 subscription_required output_format=pcm_44100",
])
#expect(TalkModeManager._test_isPCMFormatRejectedByAPI(error))
}
@Test func `ignores generic playback failures for PCM format rejection`() {
let error = NSError(
domain: "StreamingAudio",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "queue enqueue failed"])
#expect(TalkModeManager._test_isPCMFormatRejectedByAPI(error) == false)
}
}

View File

@@ -0,0 +1,28 @@
import Testing
@testable import OpenClaw
@MainActor
@Suite struct TalkModeIncrementalSpeechBufferTests {
@Test func emitsSoftBoundaryBeforeTerminalPunctuation() {
let manager = TalkModeManager(allowSimulatorCapture: true)
manager._test_incrementalReset()
let partial =
"We start speaking earlier by splitting this long stream chunk at a whitespace boundary before punctuation arrives"
let segments = manager._test_incrementalIngest(partial, isFinal: false)
#expect(segments.count == 1)
#expect(segments[0].count >= 72)
#expect(segments[0].count < partial.count)
}
@Test func keepsShortChunkBufferedWithoutPunctuation() {
let manager = TalkModeManager(allowSimulatorCapture: true)
manager._test_incrementalReset()
let short = "short chunk without punctuation"
let segments = manager._test_incrementalIngest(short, isFinal: false)
#expect(segments.isEmpty)
}
}

View File

@@ -0,0 +1,92 @@
import Testing
@testable import OpenClaw
struct TalkProStateTests {
@Test func `disabled talk without loaded config can start and retry load`() {
let state = TalkProState(
gatewayConnected: true,
isDemoMode: false,
isEnabled: false,
statusText: "Offline",
isConfigLoaded: false,
isListening: false,
isSpeaking: false,
isUserSpeechDetected: false,
permissionState: .unknown)
#expect(state.title == "Voice config unavailable")
#expect(state.primaryAction == .start)
#expect(state.primaryButtonTitle == "Start Talk")
#expect(state.waveformMode(micLevel: 0.8) == .still)
}
@Test func `enabled talk without loaded config can be stopped`() {
let state = TalkProState(
gatewayConnected: true,
isDemoMode: false,
isEnabled: true,
statusText: "Offline",
isConfigLoaded: false,
isListening: false,
isSpeaking: false,
isUserSpeechDetected: false,
permissionState: .unknown)
#expect(state.title == "Voice config unavailable")
#expect(state.primaryAction == .stop)
#expect(state.primaryButtonTitle == "Stop Talk")
#expect(state.waveformMode(micLevel: 0.8) == .still)
}
@Test func `enabled talk with loaded config can be stopped`() {
let state = TalkProState(
gatewayConnected: true,
isDemoMode: false,
isEnabled: true,
statusText: "Ready",
isConfigLoaded: true,
isListening: false,
isSpeaking: false,
isUserSpeechDetected: false,
permissionState: .ready)
#expect(state.title == "Ready to talk")
#expect(state.primaryAction == .stop)
}
@Test func `missing scope takes priority over unloaded config`() {
let state = TalkProState(
gatewayConnected: true,
isDemoMode: false,
isEnabled: false,
statusText: "Offline",
isConfigLoaded: false,
isListening: false,
isSpeaking: false,
isUserSpeechDetected: false,
permissionState: .missingScope("operator.talk.secrets"))
#expect(state.title == "Gateway permission required")
#expect(state.primaryAction == .enablePermission)
#expect(state.primaryButtonTitle == "Enable Talk")
}
@Test func `demo mode keeps talk disabled`() {
let state = TalkProState(
gatewayConnected: true,
isDemoMode: true,
isEnabled: true,
statusText: "Ready",
isConfigLoaded: true,
isListening: true,
isSpeaking: true,
isUserSpeechDetected: true,
permissionState: .ready)
#expect(state.title == "Demo mode only")
#expect(state.primaryAction == .waiting)
#expect(state.primaryButtonTitle == "Demo Mode Only")
#expect(state.primaryButtonIcon == "lock.fill")
#expect(state.waveformMode(micLevel: 0.8) == .still)
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite struct TalkSpeechLocaleTests {
@Test func localSelectionOverridesGatewayConfig() {
let locale = TalkSpeechLocale.resolvedLocaleID(
localSelection: "de-DE",
gatewaySelection: "ru-RU",
deviceLocaleID: "en-US",
supportedLocaleIDs: ["de-DE", "ru-RU", "en-US"])
#expect(locale == "de-DE")
}
@Test func automaticLocalSelectionAllowsGatewayConfig() {
let locale = TalkSpeechLocale.resolvedLocaleID(
localSelection: TalkSpeechLocale.automaticID,
gatewaySelection: "ru_RU",
deviceLocaleID: "en-US",
supportedLocaleIDs: ["ru-RU", "en-US"])
#expect(locale == "ru-RU")
}
@Test func unsupportedConfiguredLocaleFallsBackToDeviceThenEnglish() {
let deviceLocale = TalkSpeechLocale.resolvedLocaleID(
localSelection: "zz-ZZ",
gatewaySelection: nil,
deviceLocaleID: "fr-FR",
supportedLocaleIDs: ["fr-FR", "en-US"])
let english = TalkSpeechLocale.resolvedLocaleID(
localSelection: "zz-ZZ",
gatewaySelection: nil,
deviceLocaleID: "yy-YY",
supportedLocaleIDs: ["en-US"])
#expect(deviceLocale == "fr-FR")
#expect(english == "en-US")
}
}

View File

@@ -0,0 +1,96 @@
import Foundation
import Testing
@testable import OpenClaw
@testable import OpenClawKit
struct TerminalHubScreenTests {
private static func makeConfig(
url: URL,
token: String? = nil,
password: String? = nil) -> GatewayConnectConfig
{
GatewayConnectConfig(
url: url,
stableID: "manual|gateway.example.com|443",
tls: nil,
token: token,
bootstrapToken: nil,
password: password,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "ios",
clientMode: "node",
clientDisplayName: "Phone"))
}
@Test func `terminal URL flips scheme and carries only view parameter`() throws {
let config = try Self.makeConfig(
url: #require(URL(string: "wss://gateway.example.com:8443/ws")),
token: "secret-token")
let url = TerminalHubScreen.terminalURL(config: config)
#expect(url?.absoluteString == "https://gateway.example.com:8443/?view=terminal")
// Credentials must never ride in the page URL; they travel via the
// document-start auth user script instead.
#expect(url?.absoluteString.contains("secret-token") == false)
}
@Test func `terminal URL uses plain HTTP for insecure endpoints`() throws {
let config = try Self.makeConfig(url: #require(URL(string: "ws://192.168.1.10:18789")))
let url = TerminalHubScreen.terminalURL(config: config)
#expect(url?.absoluteString == "http://192.168.1.10:18789/?view=terminal")
}
@Test func `auth user script carries credentials gated to the page origin`() throws {
let config = try Self.makeConfig(
url: #require(URL(string: "wss://gateway.example.com:8443")),
token: " secret-token ",
password: "fallback-password")
let script = TerminalHubScreen.terminalAuthUserScript(config: config)
#expect(script?.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") == true)
// JSONSerialization escapes forward slashes, hence the `\/` literals.
#expect(script?.contains("\"https:\\/\\/gateway.example.com:8443\"") == true)
#expect(script?.contains("\"token\":\"secret-token\"") == true)
#expect(script?.contains("\"password\":\"fallback-password\"") == true)
#expect(script?.contains("\"gatewayUrl\":\"wss:\\/\\/gateway.example.com:8443\"") == true)
}
@Test func `auth user script falls back to stored operator token`() throws {
let config = try Self.makeConfig(
url: #require(URL(string: "wss://gateway.example.com:8443")),
token: nil,
password: nil)
let script = TerminalHubScreen.terminalAuthUserScript(
config: config,
storedOperatorToken: " stored-token ")
#expect(script?.contains("\"token\":\"stored-token\"") == true)
}
@Test func `web content identity changes with stored operator token`() throws {
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")))
#expect(
TerminalHubScreen.webContentIdentity(config: config, storedOperatorToken: "token-a") !=
TerminalHubScreen.webContentIdentity(config: config, storedOperatorToken: "token-b"))
}
@Test func `auth user script is omitted without credentials`() throws {
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")), token: " ")
#expect(
TerminalHubScreen.terminalAuthUserScript(config: config, storedOperatorToken: nil) == nil)
#expect(
TerminalHubScreen.terminalAuthUserScript(config: nil, storedOperatorToken: nil) == nil)
}
}

View File

@@ -0,0 +1,26 @@
import Foundation
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()
}

View File

@@ -0,0 +1,22 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite struct VoiceWakeGatewaySyncTests {
@Test func decodeGatewayTriggersFromJSONSanitizes() {
let payload = #"{"triggers":[" openclaw ","", "computer"]}"#
let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payload)
#expect(triggers == ["openclaw", "computer"])
}
@Test func decodeGatewayTriggersFromJSONFallsBackWhenEmpty() {
let payload = #"{"triggers":[" ",""]}"#
let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payload)
#expect(triggers == VoiceWakePreferences.defaultTriggerWords)
}
@Test func decodeGatewayTriggersFromInvalidJSONReturnsNil() {
let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: "not json")
#expect(triggers == nil)
}
}

View File

@@ -0,0 +1,79 @@
import Foundation
import SwabbleKit
import Testing
@testable import OpenClaw
private let openclawTranscript = "hey openclaw do thing"
private func openclawSegments(postTriggerStart: TimeInterval) -> [WakeWordSegment] {
makeSegments(
transcript: openclawTranscript,
words: [
("hey", 0.0, 0.1),
("openclaw", 0.2, 0.1),
("do", postTriggerStart, 0.1),
("thing", postTriggerStart + 0.2, 0.1),
])
}
@Suite struct VoiceWakeManagerExtractCommandTests {
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
let transcript = "hello world"
let segments = makeSegments(
transcript: transcript,
words: [("hello", 0.0, 0.1), ("world", 0.2, 0.1)])
#expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["openclaw"]) == nil)
}
@Test func extractCommandTrimsTokensAndResult() {
let segments = openclawSegments(postTriggerStart: 0.9)
let cmd = VoiceWakeManager.extractCommand(
from: openclawTranscript,
segments: segments,
triggers: [" openclaw "],
minPostTriggerGap: 0.3)
#expect(cmd == "do thing")
}
@Test func extractCommandReturnsNilWhenGapTooShort() {
let segments = openclawSegments(postTriggerStart: 0.35)
let cmd = VoiceWakeManager.extractCommand(
from: openclawTranscript,
segments: segments,
triggers: ["openclaw"],
minPostTriggerGap: 0.3)
#expect(cmd == nil)
}
@Test func extractCommandReturnsNilWhenNothingAfterTrigger() {
let transcript = "hey openclaw"
let segments = makeSegments(
transcript: transcript,
words: [("hey", 0.0, 0.1), ("openclaw", 0.2, 0.1)])
#expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["openclaw"]) == nil)
}
@Test func extractCommandIgnoresEmptyTriggers() {
let segments = openclawSegments(postTriggerStart: 0.9)
let cmd = VoiceWakeManager.extractCommand(
from: openclawTranscript,
segments: segments,
triggers: ["", " ", "openclaw"],
minPostTriggerGap: 0.3)
#expect(cmd == "do thing")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}

View File

@@ -0,0 +1,67 @@
import Foundation
import SwabbleKit
import Testing
@testable import OpenClaw
@Suite(.serialized) struct VoiceWakeManagerStateTests {
@Test @MainActor func `suspend and resume cycle updates state`() async {
let manager = VoiceWakeManager._test_withoutRestartDelays()
manager.isEnabled = true
manager.isListening = true
manager.statusText = "Listening"
let suspended = manager.suspendForExternalAudioCapture()
#expect(suspended == true)
#expect(manager.isListening == false)
#expect(manager.statusText == "Paused")
manager.resumeAfterExternalAudioCapture(wasSuspended: true)
await manager._test_waitForScheduledStart()
#expect(manager.statusText == "Voice Wake isnt supported on Simulator")
}
@Test @MainActor func `handle recognition callback restarts on error`() async {
let manager = VoiceWakeManager._test_withoutRestartDelays()
manager.isEnabled = true
manager.isListening = true
manager._test_handleRecognitionCallback(transcript: nil, segments: [], errorText: "boom")
#expect(manager.statusText.contains("Recognizer error") == true)
#expect(manager.isListening == false)
await manager._test_waitForScheduledStart()
#expect(manager.statusText == "Voice Wake isnt supported on Simulator")
}
@Test @MainActor func `handle recognition callback dispatches command`() async throws {
let manager = VoiceWakeManager()
manager.triggerWords = ["openclaw"]
manager.isEnabled = true
actor CaptureBox {
var value: String?
func set(_ next: String) {
self.value = next
}
}
let capture = CaptureBox()
manager.configure { cmd in
await capture.set(cmd)
}
let transcript = "openclaw hello"
let triggerRange = try #require(transcript.range(of: "openclaw"))
let helloRange = try #require(transcript.range(of: "hello"))
let segments = [
WakeWordSegment(text: "openclaw", start: 0.0, duration: 0.2, range: triggerRange),
WakeWordSegment(text: "hello", start: 0.8, duration: 0.2, range: helloRange),
]
manager._test_handleRecognitionCallback(transcript: transcript, segments: segments, errorText: nil)
#expect(manager.lastTriggeredCommand == "hello")
#expect(manager.statusText == "Triggered")
try? await Task.sleep(nanoseconds: 300_000_000)
#expect(await capture.value == "hello")
}
}

View File

@@ -0,0 +1,72 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite("Voice Wake manager suppression", .serialized)
struct VoiceWakeManagerSuppressionTests {
@Test
@MainActor func `clearing Talk suppression restarts after pending start was canceled`() async {
let manager = VoiceWakeManager._test_withoutRestartDelays()
manager.isEnabled = true
manager.statusText = "Paused"
manager.setSuppressedByTalk(true)
manager.setSuppressedByTalk(false)
await manager._test_waitForScheduledStart()
#expect(manager.statusText == "Voice Wake isnt supported on Simulator")
#expect(manager.isListening == false)
}
@Test
@MainActor func `external audio resumes pending Voice Wake restart`() async {
let manager = VoiceWakeManager._test_withoutRestartDelays()
manager.isEnabled = true
manager.resumeAfterExternalAudioCapture(wasSuspended: true)
let suspended = manager.suspendForExternalAudioCapture()
#expect(suspended == true)
manager.resumeAfterExternalAudioCapture(wasSuspended: suspended)
await manager._test_waitForScheduledStart()
#expect(manager.statusText == "Voice Wake isnt supported on Simulator")
#expect(manager.isListening == false)
}
@Test
@MainActor func `external audio resumes in flight Voice Wake start`() async {
let manager = VoiceWakeManager._test_withoutRestartDelays()
manager.isEnabled = true
manager._test_setStartInFlight(true)
let suspended = manager.suspendForExternalAudioCapture()
#expect(suspended == true)
#expect(manager.statusText == "Paused")
manager._test_setStartInFlight(false)
manager.resumeAfterExternalAudioCapture(wasSuspended: suspended)
await manager._test_waitForScheduledStart()
#expect(manager.statusText == "Voice Wake isnt supported on Simulator")
#expect(manager.isListening == false)
}
@Test
@MainActor func `Talk suppression toggle does not leave Voice Wake externally suspended`() async {
let manager = VoiceWakeManager._test_withoutRestartDelays()
manager.isEnabled = true
manager.isListening = true
manager.setSuppressedByTalk(true)
let suspended = manager.suspendForExternalAudioCapture()
#expect(suspended == false)
manager.setSuppressedByTalk(false)
manager.resumeAfterExternalAudioCapture(wasSuspended: suspended)
await manager._test_waitForScheduledStart()
#expect(manager.statusText == "Voice Wake isnt supported on Simulator")
#expect(manager.isListening == false)
}
}

View File

@@ -0,0 +1,38 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite struct VoiceWakePreferencesTests {
@Test func sanitizeTriggerWordsTrimsAndDropsEmpty() {
#expect(VoiceWakePreferences.sanitizeTriggerWords([" openclaw ", "", " \nclaude\t"]) == ["openclaw", "claude"])
}
@Test func sanitizeTriggerWordsFallsBackToDefaultsWhenEmpty() {
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
}
@Test func sanitizeTriggerWordsLimitsWordLength() {
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
}
@Test func sanitizeTriggerWordsLimitsWordCount() {
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
#expect(cleaned.count == VoiceWakePreferences.maxWords)
}
@Test func displayStringUsesSanitizedWords() {
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "openclaw, claude")
}
@Test func loadAndSaveTriggerWordsRoundTrip() {
let suiteName = "VoiceWakePreferencesTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
#expect(VoiceWakePreferences.loadTriggerWords(defaults: defaults) == VoiceWakePreferences.defaultTriggerWords)
VoiceWakePreferences.saveTriggerWords(["computer"], defaults: defaults)
#expect(VoiceWakePreferences.loadTriggerWords(defaults: defaults) == ["computer"])
}
}