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
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:
31
apps/ios/Tests/AppCoverageTests.swift
Normal file
31
apps/ios/Tests/AppCoverageTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
74
apps/ios/Tests/BackgroundAliveBeaconTests.swift
Normal file
74
apps/ios/Tests/BackgroundAliveBeaconTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
24
apps/ios/Tests/CameraControllerClampTests.swift
Normal file
24
apps/ios/Tests/CameraControllerClampTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
14
apps/ios/Tests/CameraControllerErrorTests.swift
Normal file
14
apps/ios/Tests/CameraControllerErrorTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
27
apps/ios/Tests/CommandCenterTabLayoutTests.swift
Normal file
27
apps/ios/Tests/CommandCenterTabLayoutTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
41
apps/ios/Tests/CommandCenterTabSessionFilterTests.swift
Normal file
41
apps/ios/Tests/CommandCenterTabSessionFilterTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
227
apps/ios/Tests/DeepLinkParserTests.swift
Normal file
227
apps/ios/Tests/DeepLinkParserTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
11
apps/ios/Tests/DeviceInfoHelperTests.swift
Normal file
11
apps/ios/Tests/DeviceInfoHelperTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
108
apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift
Normal file
108
apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift
Normal 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"]])
|
||||
}
|
||||
}
|
||||
452
apps/ios/Tests/GatewayConnectionControllerTests.swift
Normal file
452
apps/ios/Tests/GatewayConnectionControllerTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
33
apps/ios/Tests/GatewayConnectionIssueTests.swift
Normal file
33
apps/ios/Tests/GatewayConnectionIssueTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
324
apps/ios/Tests/GatewayConnectionSecurityTests.swift
Normal file
324
apps/ios/Tests/GatewayConnectionSecurityTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
22
apps/ios/Tests/GatewayDiscoveryModelTests.swift
Normal file
22
apps/ios/Tests/GatewayDiscoveryModelTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
33
apps/ios/Tests/GatewayEndpointIDTests.swift
Normal file
33
apps/ios/Tests/GatewayEndpointIDTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
54
apps/ios/Tests/GatewayProblemPrimaryActionTests.swift
Normal file
54
apps/ios/Tests/GatewayProblemPrimaryActionTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
188
apps/ios/Tests/GatewaySettingsStoreTests.swift
Normal file
188
apps/ios/Tests/GatewaySettingsStoreTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
apps/ios/Tests/GatewayStatusBuilderTests.swift
Normal file
56
apps/ios/Tests/GatewayStatusBuilderTests.swift
Normal 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 })
|
||||
}
|
||||
}
|
||||
202
apps/ios/Tests/IOSGatewayChatTransportTests.swift
Normal file
202
apps/ios/Tests/IOSGatewayChatTransportTests.swift
Normal 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
24
apps/ios/Tests/Info.plist
Normal 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>
|
||||
22
apps/ios/Tests/KeychainStoreTests.swift
Normal file
22
apps/ios/Tests/KeychainStoreTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
55
apps/ios/Tests/LicenseDocumentLoaderTests.swift
Normal file
55
apps/ios/Tests/LicenseDocumentLoaderTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
223
apps/ios/Tests/LocationPermissionSummaryTests.swift
Normal file
223
apps/ios/Tests/LocationPermissionSummaryTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
85
apps/ios/Tests/Logic/TalkConfigParsingTests.swift
Normal file
85
apps/ios/Tests/Logic/TalkConfigParsingTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
1869
apps/ios/Tests/NodeAppModelInvokeTests.swift
Normal file
1869
apps/ios/Tests/NodeAppModelInvokeTests.swift
Normal file
File diff suppressed because it is too large
Load Diff
86
apps/ios/Tests/NodeServicePermissionTests.swift
Normal file
86
apps/ios/Tests/NodeServicePermissionTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
108
apps/ios/Tests/OnboardingStateStoreTests.swift
Normal file
108
apps/ios/Tests/OnboardingStateStoreTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
34
apps/ios/Tests/OpenClawAppDelegateTests.swift
Normal file
34
apps/ios/Tests/OpenClawAppDelegateTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
100
apps/ios/Tests/OpenClawBrandTests.swift
Normal file
100
apps/ios/Tests/OpenClawBrandTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
409
apps/ios/Tests/OpenClawTypographyTests.swift
Normal file
409
apps/ios/Tests/OpenClawTypographyTests.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
39
apps/ios/Tests/PermissionRequestBridgeTests.swift
Normal file
39
apps/ios/Tests/PermissionRequestBridgeTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
50
apps/ios/Tests/PushBuildConfigTests.swift
Normal file
50
apps/ios/Tests/PushBuildConfigTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
107
apps/ios/Tests/RealtimeTalkRelaySessionTests.swift
Normal file
107
apps/ios/Tests/RealtimeTalkRelaySessionTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
540
apps/ios/Tests/RootTabsPresentationTests.swift
Normal file
540
apps/ios/Tests/RootTabsPresentationTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
142
apps/ios/Tests/RootTabsSidebarRegressionTests.swift
Normal file
142
apps/ios/Tests/RootTabsSidebarRegressionTests.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
1142
apps/ios/Tests/RootTabsSourceGuardTests.swift
Normal file
1142
apps/ios/Tests/RootTabsSourceGuardTests.swift
Normal file
File diff suppressed because it is too large
Load Diff
124
apps/ios/Tests/ScreenControllerTests.swift
Normal file
124
apps/ios/Tests/ScreenControllerTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
99
apps/ios/Tests/ScreenRecordServiceTests.swift
Normal file
99
apps/ios/Tests/ScreenRecordServiceTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
32
apps/ios/Tests/SettingsNetworkingHelpersTests.swift
Normal file
32
apps/ios/Tests/SettingsNetworkingHelpersTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
57
apps/ios/Tests/ShareToAgentDeepLinkTests.swift
Normal file
57
apps/ios/Tests/ShareToAgentDeepLinkTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
349
apps/ios/Tests/SwiftUIRenderSmokeTests.swift
Normal file
349
apps/ios/Tests/SwiftUIRenderSmokeTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
422
apps/ios/Tests/TalkGatewaySpeechClientTests.swift
Normal file
422
apps/ios/Tests/TalkGatewaySpeechClientTests.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
846
apps/ios/Tests/TalkModeConfigParsingTests.swift
Normal file
846
apps/ios/Tests/TalkModeConfigParsingTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
28
apps/ios/Tests/TalkModeIncrementalSpeechBufferTests.swift
Normal file
28
apps/ios/Tests/TalkModeIncrementalSpeechBufferTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
92
apps/ios/Tests/TalkProStateTests.swift
Normal file
92
apps/ios/Tests/TalkProStateTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
41
apps/ios/Tests/TalkSpeechLocaleTests.swift
Normal file
41
apps/ios/Tests/TalkSpeechLocaleTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
96
apps/ios/Tests/TerminalHubScreenTests.swift
Normal file
96
apps/ios/Tests/TerminalHubScreenTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
26
apps/ios/Tests/TestDefaultsSupport.swift
Normal file
26
apps/ios/Tests/TestDefaultsSupport.swift
Normal 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()
|
||||
}
|
||||
22
apps/ios/Tests/VoiceWakeGatewaySyncTests.swift
Normal file
22
apps/ios/Tests/VoiceWakeGatewaySyncTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
79
apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift
Normal file
79
apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift
Normal 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
|
||||
}
|
||||
67
apps/ios/Tests/VoiceWakeManagerStateTests.swift
Normal file
67
apps/ios/Tests/VoiceWakeManagerStateTests.swift
Normal 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 isn’t 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 isn’t 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")
|
||||
}
|
||||
}
|
||||
72
apps/ios/Tests/VoiceWakeManagerSuppressionTests.swift
Normal file
72
apps/ios/Tests/VoiceWakeManagerSuppressionTests.swift
Normal 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 isn’t 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 isn’t 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 isn’t 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 isn’t supported on Simulator")
|
||||
#expect(manager.isListening == false)
|
||||
}
|
||||
}
|
||||
38
apps/ios/Tests/VoiceWakePreferencesTests.swift
Normal file
38
apps/ios/Tests/VoiceWakePreferencesTests.swift
Normal 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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user