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:
43
apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift
Normal file
43
apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
struct AgentEventStoreTests {
|
||||
@Test
|
||||
func `append and clear`() {
|
||||
let store = AgentEventStore()
|
||||
#expect(store.events.isEmpty)
|
||||
|
||||
store.append(ControlAgentEvent(
|
||||
runId: "run",
|
||||
seq: 1,
|
||||
stream: "test",
|
||||
ts: 0,
|
||||
data: [:] as [String: OpenClawProtocol.AnyCodable],
|
||||
summary: nil))
|
||||
#expect(store.events.count == 1)
|
||||
|
||||
store.clear()
|
||||
#expect(store.events.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `trims to max events`() {
|
||||
let store = AgentEventStore()
|
||||
for i in 1...401 {
|
||||
store.append(ControlAgentEvent(
|
||||
runId: "run",
|
||||
seq: i,
|
||||
stream: "test",
|
||||
ts: Double(i),
|
||||
data: [:] as [String: OpenClawProtocol.AnyCodable],
|
||||
summary: nil))
|
||||
}
|
||||
|
||||
#expect(store.events.count == 400)
|
||||
#expect(store.events.first?.seq == 2)
|
||||
#expect(store.events.last?.seq == 401)
|
||||
}
|
||||
}
|
||||
112
apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift
Normal file
112
apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct AgentWorkspaceTests {
|
||||
@Test
|
||||
func `display path uses tilde for home`() {
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
#expect(AgentWorkspace.displayPath(for: home) == "~")
|
||||
|
||||
let inside = home.appendingPathComponent("Projects", isDirectory: true)
|
||||
#expect(AgentWorkspace.displayPath(for: inside).hasPrefix("~/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `resolve workspace URL expands tilde`() {
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: "~/tmp")
|
||||
#expect(url.path.hasSuffix("/tmp"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `agents URL appends filename`() {
|
||||
let root = URL(fileURLWithPath: "/tmp/ws", isDirectory: true)
|
||||
let url = AgentWorkspace.agentsURL(workspaceURL: root)
|
||||
#expect(url.lastPathComponent == AgentWorkspace.agentsFilename)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `bootstrap creates agents file when missing`() throws {
|
||||
let tmp = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: tmp) }
|
||||
|
||||
let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||
#expect(FileManager().fileExists(atPath: agentsURL.path))
|
||||
|
||||
let contents = try String(contentsOf: agentsURL, encoding: .utf8)
|
||||
#expect(contents.contains("# AGENTS.md"))
|
||||
|
||||
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
||||
let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename)
|
||||
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
||||
#expect(FileManager().fileExists(atPath: identityURL.path))
|
||||
#expect(FileManager().fileExists(atPath: userURL.path))
|
||||
#expect(FileManager().fileExists(atPath: bootstrapURL.path))
|
||||
|
||||
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||
#expect(second == agentsURL)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `bootstrap safety rejects non empty folder without agents`() throws {
|
||||
let tmp = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: tmp) }
|
||||
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
let marker = tmp.appendingPathComponent("notes.txt")
|
||||
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
||||
|
||||
let result = AgentWorkspace.bootstrapSafety(for: tmp)
|
||||
#expect(result.unsafeReason != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `bootstrap safety allows existing agents file`() throws {
|
||||
let tmp = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: tmp) }
|
||||
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
|
||||
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
|
||||
|
||||
let result = AgentWorkspace.bootstrapSafety(for: tmp)
|
||||
#expect(result.unsafeReason == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `bootstrap skips bootstrap file when workspace has content`() throws {
|
||||
let tmp = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: tmp) }
|
||||
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
let marker = tmp.appendingPathComponent("notes.txt")
|
||||
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
||||
|
||||
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||
|
||||
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
||||
#expect(!FileManager().fileExists(atPath: bootstrapURL.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `needs bootstrap false when identity already set`() throws {
|
||||
let tmp = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: tmp) }
|
||||
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
||||
try """
|
||||
# IDENTITY.md - Agent Identity
|
||||
|
||||
- Name: Clawd
|
||||
- Creature: Space Lobster
|
||||
- Vibe: Helpful
|
||||
- Emoji: lobster
|
||||
""".write(to: identityURL, atomically: true, encoding: .utf8)
|
||||
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
||||
try "bootstrap".write(to: bootstrapURL, atomically: true, encoding: .utf8)
|
||||
|
||||
#expect(!AgentWorkspace.needsBootstrap(workspaceURL: tmp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct AnyCodableEncodingTests {
|
||||
@Test func `encodes swift array and dictionary values`() throws {
|
||||
let payload: [String: Any] = [
|
||||
"tags": ["node", "ios"],
|
||||
"meta": ["count": 2],
|
||||
"null": NSNull(),
|
||||
]
|
||||
|
||||
let data = try JSONEncoder().encode(OpenClawProtocol.AnyCodable(payload))
|
||||
let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
|
||||
#expect(obj["tags"] as? [String] == ["node", "ios"])
|
||||
#expect((obj["meta"] as? [String: Any])?["count"] as? Int == 2)
|
||||
#expect(obj["null"] is NSNull)
|
||||
}
|
||||
|
||||
@Test func `protocol any codable encodes primitive arrays`() throws {
|
||||
let payload: [String: Any] = [
|
||||
"items": [1, "two", NSNull(), ["ok": true]],
|
||||
]
|
||||
|
||||
let data = try JSONEncoder().encode(OpenClawProtocol.AnyCodable(payload))
|
||||
let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
|
||||
let items = try #require(obj["items"] as? [Any])
|
||||
#expect(items.count == 4)
|
||||
#expect(items[0] as? Int == 1)
|
||||
#expect(items[1] as? String == "two")
|
||||
#expect(items[2] is NSNull)
|
||||
#expect((items[3] as? [String: Any])?["ok"] as? Bool == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct AppStateRemoteConfigTests {
|
||||
@Test
|
||||
func `updated remote gateway config sets trimmed token`() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: [:],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: "gateway.example",
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "/tmp/id_ed25519",
|
||||
remoteToken: " secret-token ",
|
||||
remoteTokenDirty: true))
|
||||
|
||||
#expect(remote["token"] as? String == "secret-token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `updated remote gateway config clears token when blank`() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["token": "old-token"],
|
||||
draft: .init(
|
||||
transport: .direct,
|
||||
remoteUrl: "wss://gateway.example",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "",
|
||||
remoteIdentity: "",
|
||||
remoteToken: " ",
|
||||
remoteTokenDirty: true))
|
||||
|
||||
#expect((remote["token"] as? String) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `updated remote gateway config pins loopback url for ssh transport`() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["url": "ws://gateway.example:18789"],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: "gateway.example",
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
|
||||
#expect(remote["transport"] as? String == "ssh")
|
||||
#expect(remote["sshTarget"] as? String == "alice@gateway.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `updated remote gateway config keeps OpenSSH opt in only for the same target`() {
|
||||
let sameTarget = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: [
|
||||
"sshHostKeyPolicy": "openssh",
|
||||
"sshTarget": "alice@gateway.example",
|
||||
],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
let changedTarget = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: [
|
||||
"sshHostKeyPolicy": "openssh",
|
||||
"sshTarget": "old-gateway-alias",
|
||||
],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "new-gateway-alias",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
|
||||
#expect(sameTarget["sshHostKeyPolicy"] as? String == "openssh")
|
||||
#expect(changedTarget["sshHostKeyPolicy"] as? String == "strict")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `updated remote gateway config preserves custom loopback tunnel port`() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["url": "ws://localhost.:29876"],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: "gateway.example",
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `updated remote gateway config preserves custom port when existing host matches ssh target`() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["url": "ws://gateway.example:19999"],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:19999")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `updated remote gateway config drops custom port when existing host does not match ssh target`() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["url": "ws://other-host.example:19999"],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: "gateway.example",
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `updated remote gateway config does not preserve port for hostname prefix collision`() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["url": "ws://example.attacker.tld:19999"],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "alice@example.com",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `app state init does not infer loopback host into remote target`() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withIsolatedState(
|
||||
env: ["OPENCLAW_CONFIG_PATH": configPath],
|
||||
defaults: [remoteTargetKey: nil])
|
||||
{
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": [
|
||||
"url": "ws://127.0.0.1:19999",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
let state = AppState(preview: true)
|
||||
#expect(state.remoteTarget == "")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `app state init preserves existing remote target when remote url is loopback`() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withIsolatedState(
|
||||
env: ["OPENCLAW_CONFIG_PATH": configPath],
|
||||
defaults: [remoteTargetKey: "alice@gateway.example"])
|
||||
{
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": [
|
||||
"url": "ws://127.0.0.1:19999",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
let state = AppState(preview: true)
|
||||
#expect(state.remoteTarget == "alice@gateway.example")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `app state init preserves legacy SSH tunnel config until transport is explicit`() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withIsolatedState(
|
||||
env: ["OPENCLAW_CONFIG_PATH": configPath],
|
||||
defaults: [remoteTargetKey: nil])
|
||||
{
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": [
|
||||
"url": "ws://127.0.0.1:18789",
|
||||
"sshTarget": "steipete@192.168.0.202",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
let state = AppState(preview: true)
|
||||
#expect(state.remoteTransport == .ssh)
|
||||
#expect(state.remoteUrl == "ws://127.0.0.1:18789")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `synced gateway root preserves object token across mode and transport changes when untouched`() {
|
||||
let initialRoot: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": [
|
||||
"transport": "direct",
|
||||
"url": "wss://old-gateway.example",
|
||||
"token": [
|
||||
"$secretRef": "gateway-token", // pragma: allowlist secret
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let sshRoot = AppState._testSyncedGatewayRoot(
|
||||
currentRoot: initialRoot,
|
||||
draft: .init(
|
||||
connectionMode: .remote,
|
||||
remoteTransport: .ssh,
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "",
|
||||
remoteUrl: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
let sshRemote = (sshRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]
|
||||
#expect((sshRemote?["token"] as? [String: String])?["$secretRef"] ==
|
||||
"gateway-token") // pragma: allowlist secret
|
||||
|
||||
let localRoot = AppState._testSyncedGatewayRoot(
|
||||
currentRoot: sshRoot,
|
||||
draft: .init(
|
||||
connectionMode: .local,
|
||||
remoteTransport: .ssh,
|
||||
remoteTarget: "",
|
||||
remoteIdentity: "",
|
||||
remoteUrl: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
let localGateway = localRoot["gateway"] as? [String: Any]
|
||||
let localRemote = localGateway?["remote"] as? [String: Any]
|
||||
#expect(localGateway?["mode"] as? String == "local")
|
||||
#expect((localRemote?["token"] as? [String: String])?["$secretRef"] ==
|
||||
"gateway-token") // pragma: allowlist secret
|
||||
}
|
||||
|
||||
@Test
|
||||
func `updated remote gateway config replaces object token when user enters plaintext`() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: [
|
||||
"token": [
|
||||
"$secretRef": "gateway-token", // pragma: allowlist secret
|
||||
],
|
||||
],
|
||||
draft: .init(
|
||||
transport: .direct,
|
||||
remoteUrl: "wss://gateway.example",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "",
|
||||
remoteIdentity: "",
|
||||
remoteToken: " fresh-token ",
|
||||
remoteTokenDirty: true))
|
||||
|
||||
#expect(remote["token"] as? String == "fresh-token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `updated remote gateway config clears object token only after explicit edit`() {
|
||||
let current: [String: Any] = [
|
||||
"token": [
|
||||
"$secretRef": "gateway-token", // pragma: allowlist secret
|
||||
],
|
||||
]
|
||||
|
||||
let preserved = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: current,
|
||||
draft: .init(
|
||||
transport: .direct,
|
||||
remoteUrl: "wss://gateway.example",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
#expect((preserved["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret
|
||||
|
||||
let cleared = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: current,
|
||||
draft: .init(
|
||||
transport: .direct,
|
||||
remoteUrl: "wss://gateway.example",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "",
|
||||
remoteIdentity: "",
|
||||
remoteToken: " ",
|
||||
remoteTokenDirty: true))
|
||||
#expect((cleared["token"] as? String) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `synced gateway root preserves gateway auth across mode changes`() {
|
||||
let initialRoot: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"auth": [
|
||||
"mode": "token",
|
||||
"token": "test-token", // pragma: allowlist secret
|
||||
],
|
||||
"remote": [
|
||||
"transport": "direct",
|
||||
"url": "wss://old-gateway.example",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let localRoot = AppState._testSyncedGatewayRoot(
|
||||
currentRoot: initialRoot,
|
||||
draft: .init(
|
||||
connectionMode: .local,
|
||||
remoteTransport: .ssh,
|
||||
remoteTarget: "",
|
||||
remoteIdentity: "",
|
||||
remoteUrl: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
let localGateway = localRoot["gateway"] as? [String: Any]
|
||||
let auth = localGateway?["auth"] as? [String: Any]
|
||||
#expect(localGateway?["mode"] as? String == "local")
|
||||
#expect(auth?["mode"] as? String == "token")
|
||||
#expect(auth?["token"] as? String == "test-token") // pragma: allowlist secret
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct AudioInputDeviceObserverTests {
|
||||
@Test func `has usable default input device returns bool`() {
|
||||
// Smoke test: verifies the composition logic runs without crashing.
|
||||
// Actual result depends on whether the host has an audio input device.
|
||||
let result = AudioInputDeviceObserver.hasUsableDefaultInputDevice()
|
||||
_ = result // suppress unused-variable warning; the assertion is "no crash"
|
||||
}
|
||||
|
||||
@Test func `has usable default input device consistent with components`() {
|
||||
// When no default UID exists, the method must return false.
|
||||
// When a default UID exists, the result must match alive-set membership.
|
||||
let uid = AudioInputDeviceObserver.defaultInputDeviceUID()
|
||||
let alive = AudioInputDeviceObserver.aliveInputDeviceUIDs()
|
||||
let expected = uid.map { alive.contains($0) } ?? false
|
||||
#expect(AudioInputDeviceObserver.hasUsableDefaultInputDevice() == expected)
|
||||
}
|
||||
}
|
||||
177
apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift
Normal file
177
apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct CLIInstallerTests {
|
||||
@Test func `installed location finds executable`() throws {
|
||||
let fm = FileManager()
|
||||
let root = fm.temporaryDirectory.appendingPathComponent(
|
||||
"openclaw-cli-installer-\(UUID().uuidString)")
|
||||
defer { try? fm.removeItem(at: root) }
|
||||
|
||||
let binDir = root.appendingPathComponent("bin")
|
||||
try fm.createDirectory(at: binDir, withIntermediateDirectories: true)
|
||||
let cli = binDir.appendingPathComponent("openclaw")
|
||||
fm.createFile(atPath: cli.path, contents: Data())
|
||||
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cli.path)
|
||||
|
||||
let found = CLIInstaller.installedLocation(
|
||||
searchPaths: [binDir.path],
|
||||
fileManager: fm)
|
||||
#expect(found == cli.path)
|
||||
|
||||
try fm.removeItem(at: cli)
|
||||
fm.createFile(atPath: cli.path, contents: Data())
|
||||
try fm.setAttributes([.posixPermissions: 0o644], ofItemAtPath: cli.path)
|
||||
|
||||
let missing = CLIInstaller.installedLocation(
|
||||
searchPaths: [binDir.path],
|
||||
fileManager: fm)
|
||||
#expect(missing == nil)
|
||||
}
|
||||
|
||||
@Test func `installer command runs the signed bundled script without a shell pipeline`() {
|
||||
let command = CLIInstaller.installScriptCommand(
|
||||
version: "2026.7.3-beta.1",
|
||||
prefix: "/Users/Test User/.openclaw",
|
||||
scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh")
|
||||
|
||||
#expect(command == [
|
||||
"/bin/bash",
|
||||
"/Applications/OpenClaw.app/Contents/Resources/install-cli.sh",
|
||||
"--json",
|
||||
"--no-onboard",
|
||||
"--prefix",
|
||||
"/Users/Test User/.openclaw",
|
||||
"--version",
|
||||
"2026.7.3-beta.1",
|
||||
])
|
||||
#expect(!command.contains("curl"))
|
||||
}
|
||||
|
||||
@Test func `managed setup requires a parseable compatible version`() {
|
||||
let location = "/Users/test/.openclaw/bin/openclaw"
|
||||
|
||||
#expect(CLIInstaller.classifyVersion(
|
||||
location: location,
|
||||
output: "OpenClaw 2026.7.3\n",
|
||||
expectedVersion: "2026.7.3") == .ready(location: location, version: "2026.7.3"))
|
||||
#expect(CLIInstaller.classifyVersion(
|
||||
location: location,
|
||||
output: "OpenClaw\n",
|
||||
expectedVersion: "2026.7.3") == .unusable(location: location))
|
||||
#expect(CLIInstaller.classifyVersion(
|
||||
location: location,
|
||||
output: "2026.6.1\n",
|
||||
expectedVersion: "2026.7.3") == .incompatible(
|
||||
location: location,
|
||||
found: "2026.6.1",
|
||||
required: "2026.7.3"))
|
||||
}
|
||||
|
||||
@Test func `compatible external CLI satisfies setup`() async throws {
|
||||
let root = FileManager().temporaryDirectory.appendingPathComponent(
|
||||
"openclaw-compatible-cli-\(UUID().uuidString)")
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let executable = root.appendingPathComponent("openclaw")
|
||||
try "#!/bin/sh\necho 'OpenClaw 2026.7.3'\n".write(
|
||||
to: executable,
|
||||
atomically: true,
|
||||
encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
|
||||
let status = await CLIInstaller.status(location: executable.path)
|
||||
|
||||
#expect(status == .ready(location: executable.path, version: "2026.7.3"))
|
||||
}
|
||||
|
||||
@Test func `matching external CLI with unsupported Node is unusable`() async throws {
|
||||
let root = FileManager().temporaryDirectory.appendingPathComponent(
|
||||
"openclaw-old-node-cli-\(UUID().uuidString)")
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let executable = root.appendingPathComponent("openclaw")
|
||||
let node = root.appendingPathComponent("node")
|
||||
try "#!/bin/sh\necho 'OpenClaw 2026.7.3'\n".write(
|
||||
to: executable,
|
||||
atomically: true,
|
||||
encoding: .utf8)
|
||||
try "#!/bin/sh\necho 'v20.18.0'\n".write(
|
||||
to: node,
|
||||
atomically: true,
|
||||
encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: node.path)
|
||||
|
||||
let status = await CLIInstaller.status(location: executable.path)
|
||||
|
||||
#expect(status == .unusable(location: executable.path))
|
||||
}
|
||||
|
||||
@Test func `CLI probe preserves environment and resolves shebang tools beside executable`() {
|
||||
let location = "/custom/bin/openclaw"
|
||||
let environment = CLIInstaller.probeEnvironment(
|
||||
location: location,
|
||||
processEnvironment: ["HOME": "/Users/test", "PATH": "/usr/bin"],
|
||||
preferredPaths: ["/opt/homebrew/bin", "/usr/bin"])
|
||||
|
||||
#expect(environment["HOME"] == "/Users/test")
|
||||
#expect(environment["PATH"] == "/custom/bin:/opt/homebrew/bin:/usr/bin")
|
||||
}
|
||||
|
||||
@Test func `managed CLI probe prefers its private runtime`() {
|
||||
let executable = "/Users/test/.openclaw/bin/openclaw"
|
||||
let environment = CLIInstaller.probeEnvironment(
|
||||
location: executable,
|
||||
processEnvironment: [:],
|
||||
preferredPaths: ["/Users/test/.nvm/versions/node/v20/bin", "/usr/bin"],
|
||||
managedExecutable: executable,
|
||||
managedRuntimeDirectory: "/Users/test/.openclaw/tools/node/bin")
|
||||
|
||||
#expect(environment["PATH"] == [
|
||||
"/Users/test/.openclaw/bin",
|
||||
"/Users/test/.openclaw/tools/node/bin",
|
||||
"/Users/test/.nvm/versions/node/v20/bin",
|
||||
"/usr/bin",
|
||||
].joined(separator: ":"))
|
||||
}
|
||||
|
||||
@Test func `successful CLI setup starts the local gateway and waits for readiness`() async {
|
||||
var didStart = false
|
||||
var didWait = false
|
||||
|
||||
let activation = await CLIInstaller.activateLocalGateway(
|
||||
mode: .local,
|
||||
paused: false,
|
||||
start: { didStart = true },
|
||||
waitUntilReady: {
|
||||
didWait = true
|
||||
return true
|
||||
})
|
||||
|
||||
#expect(didStart)
|
||||
#expect(didWait)
|
||||
#expect(activation == .ready)
|
||||
}
|
||||
|
||||
@Test func `paused CLI setup defers gateway activation`() async {
|
||||
var didStart = false
|
||||
var didWait = false
|
||||
|
||||
let activation = await CLIInstaller.activateLocalGateway(
|
||||
mode: .local,
|
||||
paused: true,
|
||||
start: { didStart = true },
|
||||
waitUntilReady: {
|
||||
didWait = true
|
||||
return true
|
||||
})
|
||||
|
||||
#expect(!didStart)
|
||||
#expect(!didWait)
|
||||
#expect(activation == .deferred)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct CameraCaptureServiceTests {
|
||||
@Test func `normalize snap defaults`() {
|
||||
let res = CameraCaptureService.normalizeSnap(maxWidth: nil, quality: nil)
|
||||
#expect(res.maxWidth == 1600)
|
||||
#expect(res.quality == 0.9)
|
||||
}
|
||||
|
||||
@Test func `normalize snap clamps values`() {
|
||||
let low = CameraCaptureService.normalizeSnap(maxWidth: -1, quality: -10)
|
||||
#expect(low.maxWidth == 1600)
|
||||
#expect(low.quality == 0.05)
|
||||
|
||||
let high = CameraCaptureService.normalizeSnap(maxWidth: 9999, quality: 10)
|
||||
#expect(high.maxWidth == 9999)
|
||||
#expect(high.quality == 1.0)
|
||||
}
|
||||
}
|
||||
61
apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift
Normal file
61
apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import Testing
|
||||
|
||||
struct CameraIPCTests {
|
||||
@Test func `camera snap codable roundtrip`() throws {
|
||||
let req: Request = .cameraSnap(
|
||||
facing: .front,
|
||||
maxWidth: 640,
|
||||
quality: 0.85,
|
||||
outPath: "/tmp/test.jpg")
|
||||
|
||||
let data = try JSONEncoder().encode(req)
|
||||
let decoded = try JSONDecoder().decode(Request.self, from: data)
|
||||
|
||||
switch decoded {
|
||||
case let .cameraSnap(facing, maxWidth, quality, outPath):
|
||||
#expect(facing == .front)
|
||||
#expect(maxWidth == 640)
|
||||
#expect(quality == 0.85)
|
||||
#expect(outPath == "/tmp/test.jpg")
|
||||
default:
|
||||
Issue.record("expected cameraSnap, got \(decoded)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `camera clip codable roundtrip`() throws {
|
||||
let req: Request = .cameraClip(
|
||||
facing: .back,
|
||||
durationMs: 3000,
|
||||
includeAudio: false,
|
||||
outPath: "/tmp/test.mp4")
|
||||
|
||||
let data = try JSONEncoder().encode(req)
|
||||
let decoded = try JSONDecoder().decode(Request.self, from: data)
|
||||
|
||||
switch decoded {
|
||||
case let .cameraClip(facing, durationMs, includeAudio, outPath):
|
||||
#expect(facing == .back)
|
||||
#expect(durationMs == 3000)
|
||||
#expect(includeAudio == false)
|
||||
#expect(outPath == "/tmp/test.mp4")
|
||||
default:
|
||||
Issue.record("expected cameraClip, got \(decoded)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `camera clip defaults include audio to true when missing`() throws {
|
||||
let json = """
|
||||
{"type":"cameraClip","durationMs":1234}
|
||||
"""
|
||||
let decoded = try JSONDecoder().decode(Request.self, from: Data(json.utf8))
|
||||
switch decoded {
|
||||
case let .cameraClip(_, durationMs, includeAudio, _):
|
||||
#expect(durationMs == 1234)
|
||||
#expect(includeAudio == true)
|
||||
default:
|
||||
Issue.record("expected cameraClip, got \(decoded)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct CanvasFileWatcherTests {
|
||||
private func makeTempDir() throws -> URL {
|
||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let dir = base.appendingPathComponent("openclaw-canvaswatch-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
@Test func `detects in place file writes`() async throws {
|
||||
let dir = try self.makeTempDir()
|
||||
defer { try? FileManager().removeItem(at: dir) }
|
||||
|
||||
let file = dir.appendingPathComponent("index.html")
|
||||
try "hello".write(to: file, atomically: false, encoding: .utf8)
|
||||
|
||||
let fired = OSAllocatedUnfairLock(initialState: false)
|
||||
let waitState = OSAllocatedUnfairLock<(fired: Bool, cont: CheckedContinuation<Void, Never>?)>(
|
||||
initialState: (false, nil))
|
||||
|
||||
func waitForFire(timeoutNs: UInt64) async -> Bool {
|
||||
await withTaskGroup(of: Bool.self) { group in
|
||||
group.addTask {
|
||||
await withCheckedContinuation { cont in
|
||||
let resumeImmediately = waitState.withLock { state in
|
||||
if state.fired { return true }
|
||||
state.cont = cont
|
||||
return false
|
||||
}
|
||||
if resumeImmediately {
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
try? await Task.sleep(nanoseconds: timeoutNs)
|
||||
return false
|
||||
}
|
||||
|
||||
let result = await group.next() ?? false
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
let watcher = CanvasFileWatcher(url: dir) {
|
||||
fired.withLock { $0 = true }
|
||||
let cont = waitState.withLock { state in
|
||||
state.fired = true
|
||||
let cont = state.cont
|
||||
state.cont = nil
|
||||
return cont
|
||||
}
|
||||
cont?.resume()
|
||||
}
|
||||
watcher.start()
|
||||
defer { watcher.stop() }
|
||||
|
||||
// Give the stream a moment to start.
|
||||
try await Task.sleep(nanoseconds: 150 * 1_000_000)
|
||||
|
||||
// Modify the file in-place (no rename). This used to be missed when only watching the directory vnode.
|
||||
let handle = try FileHandle(forUpdating: file)
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: Data(" world".utf8))
|
||||
try handle.close()
|
||||
|
||||
let ok = await waitForFire(timeoutNs: 2_000_000_000)
|
||||
#expect(ok == true)
|
||||
#expect(fired.withLock { $0 } == true)
|
||||
}
|
||||
}
|
||||
41
apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift
Normal file
41
apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import Testing
|
||||
|
||||
struct CanvasIPCTests {
|
||||
@Test func `canvas present codable roundtrip`() throws {
|
||||
let placement = CanvasPlacement(x: 10, y: 20, width: 640, height: 480)
|
||||
let req: Request = .canvasPresent(session: "main", path: "/index.html", placement: placement)
|
||||
|
||||
let data = try JSONEncoder().encode(req)
|
||||
let decoded = try JSONDecoder().decode(Request.self, from: data)
|
||||
|
||||
switch decoded {
|
||||
case let .canvasPresent(session, path, placement):
|
||||
#expect(session == "main")
|
||||
#expect(path == "/index.html")
|
||||
#expect(placement?.x == 10)
|
||||
#expect(placement?.y == 20)
|
||||
#expect(placement?.width == 640)
|
||||
#expect(placement?.height == 480)
|
||||
default:
|
||||
Issue.record("expected canvasPresent, got \(decoded)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `canvas present decodes nil placement when missing`() throws {
|
||||
let json = """
|
||||
{"type":"canvasPresent","session":"s","path":"/"}
|
||||
"""
|
||||
let decoded = try JSONDecoder().decode(Request.self, from: Data(json.utf8))
|
||||
|
||||
switch decoded {
|
||||
case let .canvasPresent(session, path, placement):
|
||||
#expect(session == "s")
|
||||
#expect(path == "/")
|
||||
#expect(placement == nil)
|
||||
default:
|
||||
Issue.record("expected canvasPresent, got \(decoded)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct CanvasWindowSmokeTests {
|
||||
@Test func `panel controller shows and hides`() async throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)")
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
|
||||
let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) }
|
||||
let controller = try CanvasWindowController(
|
||||
sessionKey: " main/invalid⚡️ ",
|
||||
root: root,
|
||||
presentation: .panel(anchorProvider: anchor))
|
||||
|
||||
#expect(controller.directoryPath.contains("main_invalid__") == true)
|
||||
|
||||
controller.applyPreferredPlacement(CanvasPlacement(x: 120, y: 200, width: 520, height: 680))
|
||||
controller.showCanvas(path: "/")
|
||||
_ = try await controller.eval(javaScript: "1 + 1")
|
||||
controller.windowDidMove(Notification(name: NSWindow.didMoveNotification))
|
||||
controller.windowDidEndLiveResize(Notification(name: NSWindow.didEndLiveResizeNotification))
|
||||
controller.hideCanvas()
|
||||
controller.close()
|
||||
}
|
||||
|
||||
@Test func `window controller shows and closes`() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)")
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
|
||||
let controller = try CanvasWindowController(
|
||||
sessionKey: "main",
|
||||
root: root,
|
||||
presentation: .window)
|
||||
|
||||
controller.showCanvas(path: "/")
|
||||
controller.windowWillClose(Notification(name: NSWindow.willCloseNotification))
|
||||
controller.hideCanvas()
|
||||
controller.close()
|
||||
}
|
||||
|
||||
@Test func `A2UI auto navigation is idempotent for current host target`() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)")
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
|
||||
let controller = try CanvasWindowController(
|
||||
sessionKey: "main",
|
||||
root: root,
|
||||
presentation: .window)
|
||||
defer { controller.close() }
|
||||
|
||||
let oldTarget = "http://127.0.0.1:18789/__openclaw__/a2ui/?platform=macos"
|
||||
let currentTarget = "http://127.0.0.1:18790/__openclaw__/a2ui/?platform=macos"
|
||||
let userTarget = "https://github.com/openclaw/openclaw"
|
||||
|
||||
#expect(controller.shouldAutoNavigateToA2UI(lastAutoTarget: nil, candidateTarget: currentTarget) == true)
|
||||
|
||||
controller.load(target: "/")
|
||||
#expect(controller.shouldAutoNavigateToA2UI(lastAutoTarget: nil, candidateTarget: currentTarget) == true)
|
||||
|
||||
controller.load(target: currentTarget)
|
||||
#expect(controller
|
||||
.shouldAutoNavigateToA2UI(lastAutoTarget: currentTarget, candidateTarget: currentTarget) == false)
|
||||
|
||||
controller.load(target: oldTarget)
|
||||
#expect(controller.shouldAutoNavigateToA2UI(lastAutoTarget: oldTarget, candidateTarget: currentTarget) == true)
|
||||
|
||||
controller.load(target: userTarget)
|
||||
#expect(controller
|
||||
.shouldAutoNavigateToA2UI(lastAutoTarget: currentTarget, candidateTarget: currentTarget) == false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
private typealias SnapshotAnyCodable = OpenClaw.AnyCodable
|
||||
|
||||
private let channelOrder = ["whatsapp", "telegram", "signal", "imessage"]
|
||||
private let channelLabels = [
|
||||
"whatsapp": "WhatsApp",
|
||||
"telegram": "Telegram",
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
]
|
||||
private let channelDefaultAccountId = [
|
||||
"whatsapp": "default",
|
||||
"telegram": "default",
|
||||
"signal": "default",
|
||||
"imessage": "default",
|
||||
]
|
||||
|
||||
@MainActor
|
||||
private func makeChannelsStore(
|
||||
channels: [String: SnapshotAnyCodable],
|
||||
ts: Double = 1_700_000_000_000) -> ChannelsStore
|
||||
{
|
||||
let store = ChannelsStore(isPreview: true)
|
||||
store.snapshot = ChannelsStatusSnapshot(
|
||||
ts: ts,
|
||||
channelOrder: channelOrder,
|
||||
channelLabels: channelLabels,
|
||||
channelDetailLabels: nil,
|
||||
channelSystemImages: nil,
|
||||
channelMeta: nil,
|
||||
channels: channels,
|
||||
channelAccounts: [:],
|
||||
channelDefaultAccountId: channelDefaultAccountId)
|
||||
return store
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct ChannelsSettingsSmokeTests {
|
||||
@Test func `channels settings builds body with snapshot`() {
|
||||
let store = makeChannelsStore(
|
||||
channels: [
|
||||
"whatsapp": SnapshotAnyCodable([
|
||||
"configured": true,
|
||||
"linked": true,
|
||||
"authAgeMs": 86_400_000,
|
||||
"self": ["e164": "+15551234567"],
|
||||
"running": true,
|
||||
"connected": false,
|
||||
"lastConnectedAt": 1_700_000_000_000,
|
||||
"lastDisconnect": [
|
||||
"at": 1_700_000_050_000,
|
||||
"status": 401,
|
||||
"error": "logged out",
|
||||
"loggedOut": true,
|
||||
],
|
||||
"reconnectAttempts": 2,
|
||||
"lastMessageAt": 1_700_000_060_000,
|
||||
"lastEventAt": 1_700_000_060_000,
|
||||
"lastError": "needs login",
|
||||
]),
|
||||
"telegram": SnapshotAnyCodable([
|
||||
"configured": true,
|
||||
"tokenSource": "env",
|
||||
"running": true,
|
||||
"mode": "polling",
|
||||
"lastStartAt": 1_700_000_000_000,
|
||||
"probe": [
|
||||
"ok": true,
|
||||
"status": 200,
|
||||
"elapsedMs": 120,
|
||||
"bot": ["id": 123, "username": "openclawbot"],
|
||||
"webhook": ["url": "https://example.com/hook", "hasCustomCert": false],
|
||||
],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
"signal": SnapshotAnyCodable([
|
||||
"configured": true,
|
||||
"baseUrl": "http://127.0.0.1:8080",
|
||||
"running": true,
|
||||
"lastStartAt": 1_700_000_000_000,
|
||||
"probe": [
|
||||
"ok": true,
|
||||
"status": 200,
|
||||
"elapsedMs": 140,
|
||||
"version": "0.12.4",
|
||||
],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
"imessage": SnapshotAnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "not configured",
|
||||
"probe": ["ok": false, "error": "imsg not found (imsg)"],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
])
|
||||
|
||||
store.whatsappLoginMessage = "Scan QR"
|
||||
store.whatsappLoginQrDataUrl =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ay7pS8AAAAASUVORK5CYII="
|
||||
|
||||
let view = ChannelsSettings(store: store)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `channels settings builds body without snapshot`() {
|
||||
let store = makeChannelsStore(
|
||||
channels: [
|
||||
"whatsapp": SnapshotAnyCodable([
|
||||
"configured": false,
|
||||
"linked": false,
|
||||
"running": false,
|
||||
"connected": false,
|
||||
"reconnectAttempts": 0,
|
||||
]),
|
||||
"telegram": SnapshotAnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "bot missing",
|
||||
"probe": [
|
||||
"ok": false,
|
||||
"status": 403,
|
||||
"error": "unauthorized",
|
||||
"elapsedMs": 120,
|
||||
],
|
||||
"lastProbeAt": 1_700_000_100_000,
|
||||
]),
|
||||
"signal": SnapshotAnyCodable([
|
||||
"configured": false,
|
||||
"baseUrl": "http://127.0.0.1:8080",
|
||||
"running": false,
|
||||
"lastError": "not configured",
|
||||
"probe": [
|
||||
"ok": false,
|
||||
"status": 404,
|
||||
"error": "unreachable",
|
||||
"elapsedMs": 200,
|
||||
],
|
||||
"lastProbeAt": 1_700_000_200_000,
|
||||
]),
|
||||
"imessage": SnapshotAnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "not configured",
|
||||
"cliPath": "imsg",
|
||||
"probe": ["ok": false, "error": "imsg not found (imsg)"],
|
||||
"lastProbeAt": 1_700_000_200_000,
|
||||
]),
|
||||
])
|
||||
|
||||
let view = ChannelsSettings(store: store)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `whatsapp login wait result keeps latest qr until connected`() {
|
||||
let store = makeChannelsStore(channels: [:])
|
||||
store.whatsappLoginQrDataUrl = "data:image/png;base64,initial"
|
||||
|
||||
store.applyWhatsAppLoginWaitResult(
|
||||
WhatsAppLoginWaitResult(
|
||||
connected: false,
|
||||
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
|
||||
qrDataUrl: "data:image/png;base64,rotated"))
|
||||
|
||||
#expect(store.whatsappLoginQrDataUrl == "data:image/png;base64,rotated")
|
||||
#expect(store.whatsappLoginConnected == false)
|
||||
|
||||
store.applyWhatsAppLoginWaitResult(
|
||||
WhatsAppLoginWaitResult(
|
||||
connected: false,
|
||||
message: "Still waiting for the QR scan. Let me know when you’ve scanned it.",
|
||||
qrDataUrl: nil))
|
||||
|
||||
#expect(store.whatsappLoginQrDataUrl == "data:image/png;base64,rotated")
|
||||
|
||||
store.applyWhatsAppLoginWaitResult(
|
||||
WhatsAppLoginWaitResult(
|
||||
connected: true,
|
||||
message: "✅ Linked! WhatsApp is ready.",
|
||||
qrDataUrl: nil))
|
||||
|
||||
#expect(store.whatsappLoginQrDataUrl == nil)
|
||||
#expect(store.whatsappLoginConnected == true)
|
||||
}
|
||||
|
||||
@Test func `whatsapp login wait budget allows one final poll`() {
|
||||
let startedAt = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
var didRunFinalWait = false
|
||||
|
||||
#expect(
|
||||
whatsappLoginWaitRequestTimeoutMs(
|
||||
startedAt: startedAt,
|
||||
timeoutMs: 1000,
|
||||
didRunFinalWait: &didRunFinalWait,
|
||||
now: Date(timeInterval: 0.25, since: startedAt)) == 750)
|
||||
#expect(didRunFinalWait == false)
|
||||
|
||||
#expect(
|
||||
whatsappLoginWaitRequestTimeoutMs(
|
||||
startedAt: startedAt,
|
||||
timeoutMs: 1000,
|
||||
didRunFinalWait: &didRunFinalWait,
|
||||
now: Date(timeInterval: 1.25, since: startedAt)) == 1)
|
||||
#expect(didRunFinalWait == true)
|
||||
|
||||
#expect(
|
||||
whatsappLoginWaitRequestTimeoutMs(
|
||||
startedAt: startedAt,
|
||||
timeoutMs: 1000,
|
||||
didRunFinalWait: &didRunFinalWait,
|
||||
now: Date(timeInterval: 1.5, since: startedAt)) == nil)
|
||||
}
|
||||
|
||||
@Test func `cached config loads return without clearing dirty draft`() async {
|
||||
let store = makeChannelsStore(channels: [:])
|
||||
store.configSchema = ConfigSchemaNode(raw: ["type": "object"])
|
||||
store.configSchemaSourceKey = "source-a"
|
||||
store.configLoaded = true
|
||||
store.configSourceKey = "source-a"
|
||||
store.configDraft = ["channels": ["discord": ["enabled": true]]]
|
||||
store.configDirty = true
|
||||
|
||||
store.resetConfigSchemaCacheIfSourceChanged("source-a")
|
||||
store.resetConfigCacheIfSourceChanged("source-a")
|
||||
|
||||
#expect(store.configSchema != nil)
|
||||
#expect(store.configDraft["channels"] != nil)
|
||||
#expect(store.configDirty == true)
|
||||
}
|
||||
|
||||
@Test func `config cache clears dirty draft when source changes`() {
|
||||
let store = makeChannelsStore(channels: [:])
|
||||
store.configSchema = ConfigSchemaNode(raw: ["type": "object"])
|
||||
store.configSchemaSourceKey = "source-a"
|
||||
store.configUiHints = ["channels.discord.enabled": ConfigUiHint(raw: ["label": "Discord"])]
|
||||
store.configLoaded = true
|
||||
store.configSourceKey = "source-a"
|
||||
store.configRoot = ["channels": ["discord": ["enabled": false]]]
|
||||
store.configDraft = ["channels": ["discord": ["enabled": true]]]
|
||||
store.configDirty = true
|
||||
|
||||
store.resetConfigSchemaCacheIfSourceChanged("source-b")
|
||||
store.resetConfigCacheIfSourceChanged("source-b")
|
||||
|
||||
#expect(store.configSchema == nil)
|
||||
#expect(store.configUiHints.isEmpty)
|
||||
#expect(store.configLoaded == false)
|
||||
#expect(store.configRoot.isEmpty)
|
||||
#expect(store.configDraft.isEmpty)
|
||||
#expect(store.configDirty == false)
|
||||
#expect(store.configSchemaSourceKey == "source-b")
|
||||
#expect(store.configSourceKey == "source-b")
|
||||
}
|
||||
|
||||
@Test func `schema response is ignored after source changes`() {
|
||||
let store = makeChannelsStore(channels: [:])
|
||||
store.configSchemaSourceKey = "source-b"
|
||||
let res = ConfigSchemaResponse(
|
||||
schema: SnapshotAnyCodable(["type": "object", "properties": ["stale": ["type": "string"]]]),
|
||||
uihints: ["stale": SnapshotAnyCodable(["label": "Stale"])],
|
||||
version: "1",
|
||||
generatedat: "now")
|
||||
|
||||
store.applyConfigSchemaResponse(res, sourceKey: "source-a")
|
||||
|
||||
#expect(store.configSchema == nil)
|
||||
#expect(store.configUiHints.isEmpty)
|
||||
#expect(store.configSchemaSourceKey == "source-b")
|
||||
}
|
||||
|
||||
@Test func `non forced config snapshots do not overwrite dirty draft`() {
|
||||
let store = makeChannelsStore(channels: [:])
|
||||
store.configSourceKey = "source-a"
|
||||
store.configLoaded = true
|
||||
store.configDraft = ["channels": ["discord": ["enabled": true]]]
|
||||
store.configDirty = true
|
||||
let snap = ConfigSnapshot(
|
||||
path: nil,
|
||||
exists: true,
|
||||
raw: nil,
|
||||
hash: nil,
|
||||
parsed: nil,
|
||||
valid: true,
|
||||
config: ["channels": SnapshotAnyCodable(["discord": ["enabled": false]])],
|
||||
issues: nil)
|
||||
|
||||
store.applyConfigSnapshot(snap, sourceKey: "source-a", force: false)
|
||||
|
||||
let channels = store.configDraft["channels"] as? [String: Any]
|
||||
let discord = channels?["discord"] as? [String: Any]
|
||||
#expect(discord?["enabled"] as? Bool == true)
|
||||
#expect(store.configDirty == true)
|
||||
|
||||
store.applyConfigSnapshot(snap, sourceKey: "source-a", force: true)
|
||||
|
||||
let forcedChannels = store.configDraft["channels"] as? [String: Any]
|
||||
let forcedDiscord = forcedChannels?["discord"] as? [String: Any]
|
||||
#expect(forcedDiscord?["enabled"] as? Bool == false)
|
||||
#expect(store.configDirty == false)
|
||||
}
|
||||
|
||||
@Test func `forced config load queues behind background load`() {
|
||||
let store = makeChannelsStore(channels: [:])
|
||||
store.configLoading = true
|
||||
store.configLoadingSourceKey = "source-a"
|
||||
|
||||
#expect(store.queueConfigReloadIfLoading(sourceKey: "source-a", force: false) == true)
|
||||
#expect(store.configForceReloadPending == false)
|
||||
|
||||
#expect(store.queueConfigReloadIfLoading(sourceKey: "source-a", force: true) == true)
|
||||
#expect(store.configForceReloadPending == true)
|
||||
|
||||
store.configForceReloadPending = false
|
||||
#expect(store.queueConfigReloadIfLoading(sourceKey: "source-b", force: false) == true)
|
||||
#expect(store.configForceReloadPending == true)
|
||||
}
|
||||
|
||||
@Test func `schema reload queues behind background load after source changes`() {
|
||||
let store = makeChannelsStore(channels: [:])
|
||||
store.configSchemaLoading = true
|
||||
store.configSchemaLoadingSourceKey = "source-a"
|
||||
|
||||
#expect(store.queueConfigSchemaReloadIfLoading(sourceKey: "source-a", force: false) == true)
|
||||
#expect(store.configSchemaReloadPending == false)
|
||||
|
||||
#expect(store.queueConfigSchemaReloadIfLoading(sourceKey: "source-a", force: true) == true)
|
||||
#expect(store.configSchemaReloadPending == true)
|
||||
|
||||
store.configSchemaReloadPending = false
|
||||
#expect(store.queueConfigSchemaReloadIfLoading(sourceKey: "source-b", force: false) == true)
|
||||
#expect(store.configSchemaReloadPending == true)
|
||||
}
|
||||
}
|
||||
415
apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift
Normal file
415
apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift
Normal file
@@ -0,0 +1,415 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct CommandResolverTests {
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
// Use a unique suite to avoid cross-suite concurrency on UserDefaults.standard.
|
||||
UserDefaults(suiteName: "CommandResolverTests.\(UUID().uuidString)")!
|
||||
}
|
||||
|
||||
private func makeLocalDefaults() -> UserDefaults {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
||||
return defaults
|
||||
}
|
||||
|
||||
private func makeProjectRootWithPnpm() throws -> (tmp: URL, pnpmPath: URL) {
|
||||
let tmp = try makeTempDirForTests()
|
||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||
try makeExecutableForTests(at: pnpmPath)
|
||||
return (tmp, pnpmPath)
|
||||
}
|
||||
|
||||
@Test func `prefers open claw binary`() throws {
|
||||
let defaults = self.makeLocalDefaults()
|
||||
|
||||
let tmp = try makeTempDirForTests()
|
||||
|
||||
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
|
||||
try makeExecutableForTests(at: openclawPath)
|
||||
|
||||
let searchPaths = [tmp.appendingPathComponent("node_modules/.bin").path]
|
||||
let cmd = CommandResolver.openclawCommand(
|
||||
subcommand: "gateway",
|
||||
defaults: defaults,
|
||||
configRoot: [:],
|
||||
searchPaths: searchPaths,
|
||||
projectRoot: tmp)
|
||||
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"]))
|
||||
}
|
||||
|
||||
@Test func `falls back to node and script`() throws {
|
||||
let defaults = self.makeLocalDefaults()
|
||||
|
||||
let tmp = try makeTempDirForTests()
|
||||
|
||||
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
||||
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
|
||||
try makeExecutableForTests(at: nodePath)
|
||||
try "#!/bin/sh\necho v22.19.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||
try makeExecutableForTests(at: scriptPath)
|
||||
|
||||
let cmd = CommandResolver.openclawCommand(
|
||||
subcommand: "rpc",
|
||||
defaults: defaults,
|
||||
configRoot: [:],
|
||||
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path],
|
||||
projectRoot: tmp)
|
||||
|
||||
#expect(cmd.count >= 3)
|
||||
if cmd.count >= 3 {
|
||||
#expect(cmd[0] == nodePath.path)
|
||||
#expect(cmd[1] == scriptPath.path)
|
||||
#expect(cmd[2] == "rpc")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `prefers open claw binary over pnpm`() throws {
|
||||
let defaults = self.makeLocalDefaults()
|
||||
|
||||
let tmp = try makeTempDirForTests()
|
||||
|
||||
let binDir = tmp.appendingPathComponent("bin")
|
||||
let openclawPath = binDir.appendingPathComponent("openclaw")
|
||||
let pnpmPath = binDir.appendingPathComponent("pnpm")
|
||||
try makeExecutableForTests(at: openclawPath)
|
||||
try makeExecutableForTests(at: pnpmPath)
|
||||
|
||||
let cmd = CommandResolver.openclawCommand(
|
||||
subcommand: "rpc",
|
||||
defaults: defaults,
|
||||
configRoot: [:],
|
||||
searchPaths: [binDir.path],
|
||||
projectRoot: tmp)
|
||||
|
||||
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "rpc"]))
|
||||
}
|
||||
|
||||
@Test func `uses open claw binary without node runtime`() throws {
|
||||
let defaults = self.makeLocalDefaults()
|
||||
|
||||
let tmp = try makeTempDirForTests()
|
||||
|
||||
let binDir = tmp.appendingPathComponent("bin")
|
||||
let openclawPath = binDir.appendingPathComponent("openclaw")
|
||||
try makeExecutableForTests(at: openclawPath)
|
||||
|
||||
let cmd = CommandResolver.openclawCommand(
|
||||
subcommand: "gateway",
|
||||
defaults: defaults,
|
||||
configRoot: [:],
|
||||
searchPaths: [binDir.path],
|
||||
projectRoot: tmp)
|
||||
|
||||
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"]))
|
||||
}
|
||||
|
||||
@Test func `falls back to pnpm`() throws {
|
||||
let defaults = self.makeLocalDefaults()
|
||||
let (tmp, pnpmPath) = try self.makeProjectRootWithPnpm()
|
||||
|
||||
let cmd = CommandResolver.openclawCommand(
|
||||
subcommand: "rpc",
|
||||
defaults: defaults,
|
||||
configRoot: [:],
|
||||
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path],
|
||||
projectRoot: tmp)
|
||||
|
||||
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "openclaw", "rpc"]))
|
||||
}
|
||||
|
||||
@Test func `pnpm keeps extra args after subcommand`() throws {
|
||||
let defaults = self.makeLocalDefaults()
|
||||
let (tmp, pnpmPath) = try self.makeProjectRootWithPnpm()
|
||||
|
||||
let cmd = CommandResolver.openclawCommand(
|
||||
subcommand: "health",
|
||||
extraArgs: ["--json", "--timeout", "5"],
|
||||
defaults: defaults,
|
||||
configRoot: [:],
|
||||
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path],
|
||||
projectRoot: tmp)
|
||||
|
||||
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "openclaw", "health", "--json"]))
|
||||
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
||||
}
|
||||
|
||||
@Test func `preferred paths start with project node bins`() throws {
|
||||
let tmp = try makeTempDirForTests()
|
||||
|
||||
let first = CommandResolver.preferredPaths(
|
||||
home: FileManager().homeDirectoryForCurrentUser,
|
||||
current: [],
|
||||
projectRoot: tmp).first
|
||||
#expect(first == tmp.appendingPathComponent("node_modules/.bin").path)
|
||||
}
|
||||
|
||||
@Test func `managed install only precedes external installs after validation`() throws {
|
||||
let home = try makeTempDirForTests()
|
||||
let managedBin = home.appendingPathComponent(".openclaw/bin")
|
||||
try FileManager().createDirectory(at: managedBin, withIntermediateDirectories: true)
|
||||
let managedExecutable = managedBin.appendingPathComponent("openclaw")
|
||||
|
||||
let fallbackPaths = CommandResolver.preferredPaths(
|
||||
home: home,
|
||||
current: [],
|
||||
projectRoot: home)
|
||||
let validatedPaths = CommandResolver.preferredPaths(
|
||||
home: home,
|
||||
current: [],
|
||||
projectRoot: home,
|
||||
validatedExecutable: managedExecutable.path)
|
||||
|
||||
let packageManagerPath = home.appendingPathComponent("Library/pnpm").path
|
||||
let fallbackManagedIndex = try #require(fallbackPaths.firstIndex(of: managedBin.path))
|
||||
let fallbackPackageManagerIndex = try #require(fallbackPaths.firstIndex(of: packageManagerPath))
|
||||
let validatedManagedIndex = try #require(validatedPaths.firstIndex(of: managedBin.path))
|
||||
let validatedPackageManagerIndex = try #require(validatedPaths.firstIndex(of: packageManagerPath))
|
||||
#expect(fallbackManagedIndex > fallbackPackageManagerIndex)
|
||||
#expect(validatedManagedIndex < validatedPackageManagerIndex)
|
||||
}
|
||||
|
||||
@Test func `node manager runtimes precede system runtimes`() throws {
|
||||
let home = try makeTempDirForTests()
|
||||
let nodeManagerBin = home.appendingPathComponent(".nvm/versions/node/v22.19.0/bin")
|
||||
try makeExecutableForTests(at: nodeManagerBin.appendingPathComponent("node"))
|
||||
|
||||
let paths = CommandResolver.preferredPaths(
|
||||
home: home,
|
||||
current: [],
|
||||
projectRoot: home)
|
||||
|
||||
let managerIndex = try #require(paths.firstIndex(of: nodeManagerBin.path))
|
||||
let systemIndex = try #require(paths.firstIndex(of: "/opt/homebrew/bin"))
|
||||
#expect(managerIndex < systemIndex)
|
||||
}
|
||||
|
||||
@Test func `preferred paths include local user bin after system bins`() throws {
|
||||
let home = try makeTempDirForTests()
|
||||
let localBin = home.appendingPathComponent(".local/bin").path
|
||||
let paths = CommandResolver.preferredPaths(
|
||||
home: home,
|
||||
current: [],
|
||||
projectRoot: home)
|
||||
|
||||
let localIndex = try #require(paths.firstIndex(of: localBin))
|
||||
let systemIndex = try #require(paths.firstIndex(of: "/bin"))
|
||||
#expect(localIndex > systemIndex)
|
||||
#expect(paths.count(where: { $0 == localBin }) == 1)
|
||||
}
|
||||
|
||||
@Test func `SSH environment replaces path without dropping inherited values`() {
|
||||
let paths = ["/usr/bin", "/bin", "/Users/test/.local/bin", "/opt/homebrew/bin"]
|
||||
let environment = CommandResolver.sshEnvironment(
|
||||
base: [
|
||||
"HOME": "/Users/test",
|
||||
"PATH": "/stale/path",
|
||||
"SSH_AUTH_SOCK": "/tmp/ssh-agent.sock",
|
||||
],
|
||||
searchPaths: paths)
|
||||
|
||||
#expect(environment["PATH"] == paths.joined(separator: ":"))
|
||||
#expect(environment["HOME"] == "/Users/test")
|
||||
#expect(environment["SSH_AUTH_SOCK"] == "/tmp/ssh-agent.sock")
|
||||
}
|
||||
|
||||
@Test func `validated CLI preference expires when the app requires a newer version`() throws {
|
||||
let defaults = self.makeDefaults()
|
||||
let root = try makeTempDirForTests()
|
||||
let executable = root.appendingPathComponent("openclaw")
|
||||
FileManager().createFile(atPath: executable.path, contents: Data())
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
defaults.set(executable.path, forKey: cliValidatedExecutableKey)
|
||||
defaults.set("2026.7.3", forKey: cliValidatedVersionKey)
|
||||
|
||||
#expect(CommandResolver.validatedOpenClawExecutable(
|
||||
defaults: defaults,
|
||||
fileManager: .default,
|
||||
requiredVersion: "2026.7.3") == executable.path)
|
||||
#expect(CommandResolver.validatedOpenClawExecutable(
|
||||
defaults: defaults,
|
||||
fileManager: .default,
|
||||
requiredVersion: "2026.8.0") == nil)
|
||||
}
|
||||
|
||||
@Test func `builds SSH command for remote mode`() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey)
|
||||
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
||||
defaults.set("/srv/openclaw", forKey: remoteProjectRootKey)
|
||||
|
||||
let cmd = CommandResolver.openclawCommand(
|
||||
subcommand: "status",
|
||||
extraArgs: ["--json"],
|
||||
defaults: defaults,
|
||||
configRoot: [:])
|
||||
|
||||
#expect(cmd.first == "/usr/bin/ssh")
|
||||
if let marker = cmd.firstIndex(of: "--") {
|
||||
#expect(cmd[marker + 1] == "openclaw@example.com")
|
||||
} else {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
#expect(cmd.contains("StrictHostKeyChecking=yes"))
|
||||
#expect(!cmd.contains("StrictHostKeyChecking=accept-new"))
|
||||
#expect(cmd.contains("UpdateHostKeys=yes"))
|
||||
#expect(cmd.contains("ControlPath=none"))
|
||||
#expect(cmd.contains("-i"))
|
||||
#expect(cmd.contains("/tmp/id_ed25519"))
|
||||
if let script = cmd.last {
|
||||
#expect(script.contains("PRJ='/srv/openclaw'"))
|
||||
#expect(script.contains("cd \"$PRJ\""))
|
||||
#expect(script.contains("openclaw"))
|
||||
#expect(script.contains("status"))
|
||||
#expect(script.contains("--json"))
|
||||
#expect(script.contains("CLI="))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `explicit SSH config host key policy omits strict override`() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
defaults.set("gateway-alias", forKey: remoteTargetKey)
|
||||
|
||||
let cmd = CommandResolver.openclawCommand(
|
||||
subcommand: "status",
|
||||
defaults: defaults,
|
||||
configRoot: [
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": [
|
||||
"sshHostKeyPolicy": "openssh",
|
||||
"sshTarget": "gateway-alias",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
#expect(cmd.first == "/usr/bin/ssh")
|
||||
#expect(!cmd.contains { $0.hasPrefix("StrictHostKeyChecking=") })
|
||||
#expect(cmd.contains("ControlPath=none"))
|
||||
}
|
||||
|
||||
@Test func `OpenSSH host key opt in does not transfer to a different effective target`() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
defaults.set("new-gateway-alias", forKey: remoteTargetKey)
|
||||
|
||||
let cmd = CommandResolver.openclawCommand(
|
||||
subcommand: "status",
|
||||
defaults: defaults,
|
||||
configRoot: [
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": [
|
||||
"sshHostKeyPolicy": "openssh",
|
||||
"sshTarget": "old-gateway-alias",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
#expect(cmd.contains("StrictHostKeyChecking=yes"))
|
||||
#expect(cmd.contains("UpdateHostKeys=yes"))
|
||||
}
|
||||
|
||||
@Test func `invalid SSH host key policy fails closed`() {
|
||||
let settings = CommandResolver.connectionSettings(configRoot: [
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": ["sshHostKeyPolicy": " OPENSSH "],
|
||||
],
|
||||
])
|
||||
|
||||
#expect(settings.sshHostKeyPolicy == .strict)
|
||||
}
|
||||
|
||||
@Test func `remote gateway probe applies SSH host key policy`() throws {
|
||||
let strict = try #require(RemoteGatewayProbe._testSSHCheckCommand(
|
||||
target: "gateway-alias",
|
||||
hostKeyPolicy: .strict))
|
||||
let openssh = try #require(RemoteGatewayProbe._testSSHCheckCommand(
|
||||
target: "gateway-alias",
|
||||
hostKeyPolicy: .openssh))
|
||||
|
||||
#expect(strict.contains("StrictHostKeyChecking=yes"))
|
||||
#expect(strict.contains("UpdateHostKeys=yes"))
|
||||
#expect(strict.contains("ControlPath=none"))
|
||||
#expect(!openssh.contains { $0.hasPrefix("StrictHostKeyChecking=") })
|
||||
#expect(!openssh.contains { $0.hasPrefix("UpdateHostKeys=") })
|
||||
#expect(openssh.contains("ControlPath=none"))
|
||||
}
|
||||
|
||||
@Test func `empty remote defaults fall back to config remote values`() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
defaults.set(" ", forKey: remoteTargetKey)
|
||||
defaults.set("", forKey: remoteIdentityKey)
|
||||
|
||||
let settings = CommandResolver.connectionSettings(
|
||||
defaults: defaults,
|
||||
configRoot: [
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": [
|
||||
"sshTarget": "alice@gateway.local",
|
||||
"sshIdentity": "/tmp/config-id",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
#expect(settings.target == "alice@gateway.local")
|
||||
#expect(settings.identity == "/tmp/config-id")
|
||||
}
|
||||
|
||||
@Test func `rejects unsafe SSH targets`() {
|
||||
#expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil)
|
||||
#expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil)
|
||||
#expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222)
|
||||
}
|
||||
|
||||
@Test func `config root local overrides remote defaults`() throws {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey)
|
||||
|
||||
let tmp = try makeTempDirForTests()
|
||||
|
||||
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
|
||||
try makeExecutableForTests(at: openclawPath)
|
||||
|
||||
let cmd = CommandResolver.openclawCommand(
|
||||
subcommand: "daemon",
|
||||
defaults: defaults,
|
||||
configRoot: ["gateway": ["mode": "local"]],
|
||||
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path],
|
||||
projectRoot: tmp)
|
||||
|
||||
#expect(cmd.first == openclawPath.path)
|
||||
#expect(cmd.count >= 2)
|
||||
if cmd.count >= 2 {
|
||||
#expect(cmd[1] == "daemon")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `remote settings fall back to config ssh target`() {
|
||||
let defaults = self.makeDefaults()
|
||||
let settings = CommandResolver.connectionSettings(
|
||||
defaults: defaults,
|
||||
configRoot: [
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": [
|
||||
"sshTarget": "alice@gateway.example:2222",
|
||||
"sshIdentity": "/tmp/id_ed25519",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
#expect(settings.mode == .remote)
|
||||
#expect(settings.target == "alice@gateway.example:2222")
|
||||
#expect(settings.identity == "/tmp/id_ed25519")
|
||||
}
|
||||
}
|
||||
141
apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift
Normal file
141
apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct ConfigStoreTests {
|
||||
@Test func `load uses remote in remote mode`() async {
|
||||
var localHit = false
|
||||
var remoteHit = false
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { true },
|
||||
loadLocal: { localHit = true; return ["local": true] },
|
||||
loadRemote: { remoteHit = true; return ["remote": true] }))
|
||||
|
||||
let result = await ConfigStore.load()
|
||||
|
||||
await ConfigStore._testClearOverrides()
|
||||
#expect(remoteHit)
|
||||
#expect(!localHit)
|
||||
#expect(result["remote"] as? Bool == true)
|
||||
}
|
||||
|
||||
@Test func `load uses local in local mode`() async {
|
||||
var localHit = false
|
||||
var remoteHit = false
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { false },
|
||||
loadLocal: { localHit = true; return ["local": true] },
|
||||
loadRemote: { remoteHit = true; return ["remote": true] }))
|
||||
|
||||
let result = await ConfigStore.load()
|
||||
|
||||
await ConfigStore._testClearOverrides()
|
||||
#expect(localHit)
|
||||
#expect(!remoteHit)
|
||||
#expect(result["local"] as? Bool == true)
|
||||
}
|
||||
|
||||
@Test func `save routes to remote in remote mode`() async throws {
|
||||
var localHit = false
|
||||
var remoteHit = false
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { true },
|
||||
saveLocal: { _ in localHit = true },
|
||||
saveRemote: { _ in remoteHit = true }))
|
||||
|
||||
try await ConfigStore.save(["remote": true])
|
||||
|
||||
await ConfigStore._testClearOverrides()
|
||||
#expect(remoteHit)
|
||||
#expect(!localHit)
|
||||
}
|
||||
|
||||
@Test func `save routes to local in local mode`() async throws {
|
||||
var localHit = false
|
||||
var remoteHit = false
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { false },
|
||||
saveLocal: { _ in localHit = true },
|
||||
saveRemote: { _ in remoteHit = true }))
|
||||
|
||||
try await ConfigStore.save(["local": true])
|
||||
|
||||
await ConfigStore._testClearOverrides()
|
||||
#expect(localHit)
|
||||
#expect(!remoteHit)
|
||||
}
|
||||
|
||||
@Test func `local save does not fall back to direct write after stale gateway rejection`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "local",
|
||||
"auth": [
|
||||
"mode": "token",
|
||||
"token": "test-token", // pragma: allowlist secret
|
||||
],
|
||||
],
|
||||
])
|
||||
let before = try String(contentsOf: configPath, encoding: .utf8)
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { false },
|
||||
saveGateway: { _ in
|
||||
throw NSError(domain: "Gateway", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "config changed since last load; re-run config.get and retry",
|
||||
])
|
||||
}))
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
try await ConfigStore.save(["browser": ["enabled": false]])
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
await ConfigStore._testClearOverrides()
|
||||
|
||||
#expect(didThrow)
|
||||
let after = try String(contentsOf: configPath, encoding: .utf8)
|
||||
#expect(after == before)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `local save can fall back to protected direct write when gateway is unavailable`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { false },
|
||||
saveGateway: { _ in
|
||||
throw NSError(domain: "Gateway", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "gateway not configured",
|
||||
])
|
||||
}))
|
||||
try await ConfigStore.save([
|
||||
"gateway": ["mode": "local"],
|
||||
"browser": ["enabled": false],
|
||||
])
|
||||
await ConfigStore._testClearOverrides()
|
||||
|
||||
let data = try Data(contentsOf: configPath)
|
||||
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
#expect(((root?["browser"] as? [String: Any])?["enabled"] as? Bool) == false)
|
||||
#expect((root?["meta"] as? [String: Any]) != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawMacCLI
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ConfigureRemoteCommandTests {
|
||||
@Test @MainActor func `configure remote writes ssh config and app defaults`() async throws {
|
||||
let configURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-configure-remote-\(UUID().uuidString).json")
|
||||
defer { try? FileManager().removeItem(at: configURL) }
|
||||
|
||||
let defaultSuites = [
|
||||
"ConfigureRemoteCommandTests.release.\(UUID().uuidString)",
|
||||
"ConfigureRemoteCommandTests.debug.\(UUID().uuidString)",
|
||||
]
|
||||
let defaultsBySuite = defaultSuites.compactMap { suite in
|
||||
UserDefaults(suiteName: suite).map { (suite, $0) }
|
||||
}
|
||||
defer {
|
||||
for (suite, _) in defaultsBySuite {
|
||||
UserDefaults.standard.removePersistentDomain(forName: suite)
|
||||
}
|
||||
}
|
||||
|
||||
try await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configURL.path]) {
|
||||
let output = try configureRemote(
|
||||
.init(
|
||||
sshTarget: "alice@gateway.example",
|
||||
localPort: 19089,
|
||||
remotePort: 18789,
|
||||
sshHostKeyPolicy: "openssh",
|
||||
token: "test-token", // pragma: allowlist secret
|
||||
password: nil,
|
||||
identity: nil,
|
||||
projectRoot: nil,
|
||||
cliPath: "/opt/homebrew/bin/openclaw"),
|
||||
defaultsSuites: defaultSuites)
|
||||
|
||||
#expect(output.status == "ok")
|
||||
#expect(output.localUrl == "ws://127.0.0.1:19089")
|
||||
#expect(output.remotePort == 18789)
|
||||
#expect(output.sshHostKeyPolicy == "openssh")
|
||||
|
||||
let data = try Data(contentsOf: configURL)
|
||||
let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let gateway = try #require(root["gateway"] as? [String: Any])
|
||||
let remote = try #require(gateway["remote"] as? [String: Any])
|
||||
#expect(gateway["mode"] as? String == "remote")
|
||||
#expect(gateway["port"] as? Int == 19089)
|
||||
#expect(remote["transport"] as? String == "ssh")
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:19089")
|
||||
#expect(remote["remotePort"] as? Int == 18789)
|
||||
#expect(remote["sshTarget"] as? String == "alice@gateway.example")
|
||||
#expect(remote["sshHostKeyPolicy"] as? String == "openssh")
|
||||
#expect(remote["token"] as? String == "test-token") // pragma: allowlist secret
|
||||
|
||||
for (_, defaults) in defaultsBySuite {
|
||||
#expect(defaults.string(forKey: "openclaw.connectionMode") == "remote")
|
||||
#expect(defaults.string(forKey: "openclaw.remoteTarget") == "alice@gateway.example")
|
||||
#expect(defaults.bool(forKey: "openclaw.onboardingSeen") == true)
|
||||
#expect(defaults.string(forKey: "openclaw.remoteCliPath") == "/opt/homebrew/bin/openclaw")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func `configure remote preserves existing optional credentials when flags omitted`() async throws {
|
||||
let configURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-configure-remote-preserve-\(UUID().uuidString).json")
|
||||
defer { try? FileManager().removeItem(at: configURL) }
|
||||
|
||||
let initial: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"token": "keep-token", // pragma: allowlist secret
|
||||
"sshIdentity": "/tmp/id",
|
||||
"sshHostKeyPolicy": "openssh",
|
||||
"sshTarget": "alice@gateway.example",
|
||||
],
|
||||
],
|
||||
]
|
||||
let initialData = try JSONSerialization.data(withJSONObject: initial, options: [.prettyPrinted])
|
||||
try FileManager().createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try initialData.write(to: configURL)
|
||||
|
||||
try await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configURL.path]) {
|
||||
try configureRemote(.init(sshTarget: "alice@gateway.example"), defaultsSuites: [])
|
||||
|
||||
let data = try Data(contentsOf: configURL)
|
||||
let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let gateway = try #require(root["gateway"] as? [String: Any])
|
||||
let remote = try #require(gateway["remote"] as? [String: Any])
|
||||
#expect(remote["token"] as? String == "keep-token") // pragma: allowlist secret
|
||||
#expect(remote["sshIdentity"] as? String == "/tmp/id")
|
||||
#expect(remote["sshHostKeyPolicy"] as? String == "openssh")
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func `configure remote defaults SSH host key policy to strict`() async throws {
|
||||
let configURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-configure-remote-strict-\(UUID().uuidString).json")
|
||||
defer { try? FileManager().removeItem(at: configURL) }
|
||||
|
||||
let initial: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"sshHostKeyPolicy": "openssh",
|
||||
"sshTarget": "old-gateway-alias",
|
||||
],
|
||||
],
|
||||
]
|
||||
let initialData = try JSONSerialization.data(withJSONObject: initial, options: [.prettyPrinted])
|
||||
try initialData.write(to: configURL)
|
||||
|
||||
try await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configURL.path]) {
|
||||
let output = try configureRemote(.init(sshTarget: "gateway-alias"), defaultsSuites: [])
|
||||
|
||||
#expect(output.sshHostKeyPolicy == "strict")
|
||||
let data = try Data(contentsOf: configURL)
|
||||
let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let gateway = try #require(root["gateway"] as? [String: Any])
|
||||
let remote = try #require(gateway["remote"] as? [String: Any])
|
||||
#expect(remote["sshHostKeyPolicy"] as? String == "strict")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `configure remote rejects invalid explicit ports`() throws {
|
||||
#expect(throws: Error.self) {
|
||||
_ = try ConfigureRemoteOptions.parse(["--ssh-target", "alice@gateway.example", "--remote-port", "99999"])
|
||||
}
|
||||
#expect(throws: Error.self) {
|
||||
_ = try ConfigureRemoteOptions.parse(["--ssh-target", "alice@gateway.example", "--local-port", "nope"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `configure remote validates SSH host key policy`() throws {
|
||||
#expect(ConfigureRemoteOptions().sshHostKeyPolicy == nil)
|
||||
#expect(try ConfigureRemoteOptions.parse([
|
||||
"--ssh-target", "gateway-alias",
|
||||
"--ssh-host-key-policy", "openssh",
|
||||
]).sshHostKeyPolicy == "openssh")
|
||||
#expect(throws: Error.self) {
|
||||
_ = try ConfigureRemoteOptions.parse([
|
||||
"--ssh-target", "gateway-alias",
|
||||
"--ssh-host-key-policy", "accept-new",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `configure remote rejects ssh targets without a host`() throws {
|
||||
#expect(throws: Error.self) {
|
||||
try configureRemote(.init(sshTarget: "user@"))
|
||||
}
|
||||
#expect(throws: Error.self) {
|
||||
try configureRemote(.init(sshTarget: "alice@:2222"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func `configure remote can write direct private url`() async throws {
|
||||
let configURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-configure-direct-\(UUID().uuidString).json")
|
||||
defer { try? FileManager().removeItem(at: configURL) }
|
||||
|
||||
let initial: [String: Any] = [
|
||||
"gateway": [
|
||||
"port": 19089,
|
||||
"remote": ["sshHostKeyPolicy": "openssh"],
|
||||
],
|
||||
]
|
||||
let initialData = try JSONSerialization.data(withJSONObject: initial, options: [.prettyPrinted])
|
||||
try FileManager().createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try initialData.write(to: configURL)
|
||||
|
||||
try await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configURL.path]) {
|
||||
let output = try configureRemote(
|
||||
.init(
|
||||
directUrl: "ws://192.168.0.202:18789",
|
||||
token: "test-token"), // pragma: allowlist secret
|
||||
defaultsSuites: [])
|
||||
|
||||
#expect(output.transport == "direct")
|
||||
#expect(output.remoteUrl == "ws://192.168.0.202:18789")
|
||||
#expect(output.localUrl == nil)
|
||||
#expect(output.sshTarget == nil)
|
||||
#expect(output.sshHostKeyPolicy == nil)
|
||||
|
||||
let data = try Data(contentsOf: configURL)
|
||||
let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let gateway = try #require(root["gateway"] as? [String: Any])
|
||||
let remote = try #require(gateway["remote"] as? [String: Any])
|
||||
#expect(gateway["mode"] as? String == "remote")
|
||||
#expect(gateway["port"] as? Int == 19089)
|
||||
#expect(remote["transport"] as? String == "direct")
|
||||
#expect(remote["url"] as? String == "ws://192.168.0.202:18789")
|
||||
#expect(remote["remotePort"] == nil)
|
||||
#expect(remote["sshTarget"] == nil)
|
||||
#expect(remote["sshHostKeyPolicy"] == nil)
|
||||
#expect(remote["token"] as? String == "test-token") // pragma: allowlist secret
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func `configure remote rejects plaintext public prefix bypass`() async {
|
||||
let configURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-configure-direct-reject-\(UUID().uuidString).json")
|
||||
defer { try? FileManager().removeItem(at: configURL) }
|
||||
|
||||
_ = await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configURL.path]) {
|
||||
#expect(throws: Error.self) {
|
||||
try configureRemote(.init(directUrl: "ws://fd-example.com:18789"))
|
||||
}
|
||||
#expect(throws: Error.self) {
|
||||
try configureRemote(.init(directUrl: "ws://192.168.0.202.attacker.example:18789"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct ControlChannelStateDebouncerTests {
|
||||
@Test func `terminal states apply immediately`() {
|
||||
let start = Date(timeIntervalSince1970: 1_000)
|
||||
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
|
||||
|
||||
let degradedDelay = debouncer.delayBeforeApplying(
|
||||
currentState: .connecting,
|
||||
newState: .degraded("gateway unavailable"),
|
||||
now: start.addingTimeInterval(0.1))
|
||||
#expect(degradedDelay != nil)
|
||||
|
||||
let connectedDelay = debouncer.delayBeforeApplying(
|
||||
currentState: .connecting,
|
||||
newState: .connected,
|
||||
now: start.addingTimeInterval(0.2))
|
||||
#expect(connectedDelay == nil)
|
||||
|
||||
let afterTerminalDelay = debouncer.delayBeforeApplying(
|
||||
currentState: .connected,
|
||||
newState: .connecting,
|
||||
now: start.addingTimeInterval(0.3))
|
||||
#expect(afterTerminalDelay == nil)
|
||||
}
|
||||
|
||||
@Test func `nonterminal states are debounced within interval`() {
|
||||
let start = Date(timeIntervalSince1970: 1_000)
|
||||
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
|
||||
|
||||
let soonDelay = debouncer.delayBeforeApplying(
|
||||
currentState: .connecting,
|
||||
newState: .degraded("gateway unavailable"),
|
||||
now: start.addingTimeInterval(0.1))
|
||||
#expect(soonDelay != nil)
|
||||
#expect(abs((soonDelay ?? 0) - 0.4) < 0.001)
|
||||
|
||||
let afterWindowDelay = debouncer.delayBeforeApplying(
|
||||
currentState: .connecting,
|
||||
newState: .degraded("gateway unavailable"),
|
||||
now: start.addingTimeInterval(0.6))
|
||||
#expect(afterWindowDelay == nil)
|
||||
}
|
||||
|
||||
@Test func `deferred apply resets debounce window`() {
|
||||
let start = Date(timeIntervalSince1970: 1_000)
|
||||
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
|
||||
|
||||
debouncer.recordDeferredApply(at: start.addingTimeInterval(0.5))
|
||||
|
||||
let delayAfterDeferredUpdate = debouncer.delayBeforeApplying(
|
||||
currentState: .degraded("gateway unavailable"),
|
||||
newState: .connecting,
|
||||
now: start.addingTimeInterval(0.7))
|
||||
#expect(delayAfterDeferredUpdate != nil)
|
||||
#expect(abs((delayAfterDeferredUpdate ?? 0) - 0.3) < 0.001)
|
||||
}
|
||||
}
|
||||
24
apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift
Normal file
24
apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
struct CoverageDumpTests {
|
||||
@Test func `periodically flush coverage`() async {
|
||||
guard ProcessInfo.processInfo.environment["LLVM_PROFILE_FILE"] != nil else { return }
|
||||
guard let writeProfile = resolveProfileWriteFile() else { return }
|
||||
let deadline = Date().addingTimeInterval(4)
|
||||
while Date() < deadline {
|
||||
_ = writeProfile()
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private typealias ProfileWriteFn = @convention(c) () -> Int32
|
||||
|
||||
private func resolveProfileWriteFile() -> ProfileWriteFn? {
|
||||
let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "__llvm_profile_write_file")
|
||||
guard let symbol else { return nil }
|
||||
return unsafeBitCast(symbol, to: ProfileWriteFn.self)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
struct CritterIconRendererTests {
|
||||
@Test func `make icon renders expected size`() {
|
||||
let image = CritterIconRenderer.makeIcon(
|
||||
blink: 0.25,
|
||||
legWiggle: 0.5,
|
||||
earWiggle: 0.2,
|
||||
earScale: 1,
|
||||
earHoles: true,
|
||||
badge: nil)
|
||||
|
||||
#expect(image.size.width == 18)
|
||||
#expect(image.size.height == 18)
|
||||
#expect(image.tiffRepresentation != nil)
|
||||
}
|
||||
|
||||
@Test func `make icon renders with badge`() {
|
||||
let image = CritterIconRenderer.makeIcon(
|
||||
blink: 0,
|
||||
legWiggle: 0,
|
||||
earWiggle: 0,
|
||||
earScale: 1,
|
||||
earHoles: false,
|
||||
badge: .init(symbolName: "terminal.fill", prominence: .primary))
|
||||
|
||||
#expect(image.tiffRepresentation != nil)
|
||||
}
|
||||
|
||||
@Test func `critter status label exercises helpers`() async {
|
||||
await CritterStatusLabel.exerciseForTesting()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct CronJobEditorSmokeTests {
|
||||
private func makeEditor(job: CronJob? = nil, channelsStore: ChannelsStore? = nil) -> CronJobEditor {
|
||||
CronJobEditor(
|
||||
job: job,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
channelsStore: channelsStore ?? ChannelsStore(isPreview: true),
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
}
|
||||
|
||||
@Test func `status pill builds body`() {
|
||||
_ = StatusPill(text: "ok", tint: .green).body
|
||||
_ = StatusPill(text: "disabled", tint: .secondary).body
|
||||
}
|
||||
|
||||
@Test func `cron job editor builds body for new job`() {
|
||||
let view = self.makeEditor()
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `cron job editor builds body for existing job`() {
|
||||
let channelsStore = ChannelsStore(isPreview: true)
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_000_000,
|
||||
schedule: .every(everyMs: 3_600_000, anchorMs: 1_700_000_000_000),
|
||||
sessionTarget: .isolated,
|
||||
wakeMode: .nextHeartbeat,
|
||||
payload: .agentTurn(
|
||||
message: "Summarize the last day",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: nil,
|
||||
channel: nil,
|
||||
to: nil,
|
||||
bestEffortDeliver: nil),
|
||||
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_100_000,
|
||||
runningAtMs: nil,
|
||||
lastRunAtMs: 1_700_000_050_000,
|
||||
lastStatus: "ok",
|
||||
lastError: nil,
|
||||
lastDurationMs: 1000))
|
||||
|
||||
let view = self.makeEditor(job: job, channelsStore: channelsStore)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `cron job editor exercises builders`() {
|
||||
var view = self.makeEditor()
|
||||
view.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func `cron job editor includes delete after run for at schedule`() {
|
||||
let view = self.makeEditor()
|
||||
|
||||
var root: [String: Any] = [:]
|
||||
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true)
|
||||
let raw = root["deleteAfterRun"] as? Bool
|
||||
#expect(raw == true)
|
||||
}
|
||||
}
|
||||
203
apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift
Normal file
203
apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct CronModelsTests {
|
||||
private func makeCronJob(
|
||||
name: String,
|
||||
payloadText: String,
|
||||
state: CronJobState = CronJobState()) -> CronJob
|
||||
{
|
||||
CronJob(
|
||||
id: "x",
|
||||
agentId: nil,
|
||||
name: name,
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(at: "2026-02-03T18:00:00Z"),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: payloadText),
|
||||
delivery: nil,
|
||||
state: state)
|
||||
}
|
||||
|
||||
@Test func `schedule at encodes and decodes`() throws {
|
||||
let schedule = CronSchedule.at(at: "2026-02-03T18:00:00Z")
|
||||
let data = try JSONEncoder().encode(schedule)
|
||||
let decoded = try JSONDecoder().decode(CronSchedule.self, from: data)
|
||||
#expect(decoded == schedule)
|
||||
}
|
||||
|
||||
@Test func `schedule at decodes legacy at ms`() throws {
|
||||
let json = """
|
||||
{"kind":"at","atMs":1700000000000}
|
||||
"""
|
||||
let decoded = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8))
|
||||
if case let .at(at) = decoded {
|
||||
#expect(at.hasPrefix("2023-"))
|
||||
} else {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `schedule every encodes and decodes with anchor`() throws {
|
||||
let schedule = CronSchedule.every(everyMs: 5000, anchorMs: 10000)
|
||||
let data = try JSONEncoder().encode(schedule)
|
||||
let decoded = try JSONDecoder().decode(CronSchedule.self, from: data)
|
||||
#expect(decoded == schedule)
|
||||
}
|
||||
|
||||
@Test func `schedule cron encodes and decodes with timezone`() throws {
|
||||
let schedule = CronSchedule.cron(expr: "*/5 * * * *", tz: "Europe/Vienna")
|
||||
let data = try JSONEncoder().encode(schedule)
|
||||
let decoded = try JSONDecoder().decode(CronSchedule.self, from: data)
|
||||
#expect(decoded == schedule)
|
||||
}
|
||||
|
||||
@Test func `payload agent turn encodes and decodes`() throws {
|
||||
let payload = CronPayload.agentTurn(
|
||||
message: "hello",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 15,
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: false)
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let decoded = try JSONDecoder().decode(CronPayload.self, from: data)
|
||||
#expect(decoded == payload)
|
||||
}
|
||||
|
||||
@Test func `job encodes and decodes delete after run`() throws {
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: nil,
|
||||
name: "One-shot",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(at: "2026-02-03T18:00:00Z"),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "ping"),
|
||||
delivery: nil,
|
||||
state: CronJobState())
|
||||
let data = try JSONEncoder().encode(job)
|
||||
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
|
||||
#expect(decoded.deleteAfterRun == true)
|
||||
}
|
||||
|
||||
@Test func `schedule decode rejects unknown kind`() {
|
||||
let json = """
|
||||
{"kind":"wat","at":"2026-02-03T18:00:00Z"}
|
||||
"""
|
||||
#expect(throws: DecodingError.self) {
|
||||
_ = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `payload decode rejects unknown kind`() {
|
||||
let json = """
|
||||
{"kind":"wat","text":"hello"}
|
||||
"""
|
||||
#expect(throws: DecodingError.self) {
|
||||
_ = try JSONDecoder().decode(CronPayload.self, from: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `display name trims whitespace and falls back`() {
|
||||
let base = self.makeCronJob(name: " hello ", payloadText: "hi")
|
||||
#expect(base.displayName == "hello")
|
||||
|
||||
var unnamed = base
|
||||
unnamed.name = " "
|
||||
#expect(unnamed.displayName == "Untitled job")
|
||||
}
|
||||
|
||||
@Test func `next run date and last run date derive from state`() {
|
||||
let job = self.makeCronJob(
|
||||
name: "t",
|
||||
payloadText: "hi",
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_000_000,
|
||||
runningAtMs: nil,
|
||||
lastRunAtMs: 1_700_000_050_000,
|
||||
lastStatus: nil,
|
||||
lastError: nil,
|
||||
lastDurationMs: nil))
|
||||
#expect(job.nextRunDate == Date(timeIntervalSince1970: 1_700_000_000))
|
||||
#expect(job.lastRunDate == Date(timeIntervalSince1970: 1_700_000_050))
|
||||
}
|
||||
|
||||
@Test func `decode cron list response skips malformed jobs`() throws {
|
||||
let json = """
|
||||
{
|
||||
"jobs": [
|
||||
{
|
||||
"id": "good",
|
||||
"name": "Healthy job",
|
||||
"enabled": true,
|
||||
"createdAtMs": 1,
|
||||
"updatedAtMs": 2,
|
||||
"schedule": { "kind": "at", "at": "2026-03-01T10:00:00Z" },
|
||||
"sessionTarget": "main",
|
||||
"wakeMode": "now",
|
||||
"payload": { "kind": "systemEvent", "text": "hello" },
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "bad",
|
||||
"name": "Broken job",
|
||||
"enabled": true,
|
||||
"createdAtMs": 1,
|
||||
"updatedAtMs": 2,
|
||||
"schedule": { "kind": "at", "at": "2026-03-01T10:00:00Z" },
|
||||
"payload": { "kind": "systemEvent", "text": "hello" },
|
||||
"state": {}
|
||||
}
|
||||
],
|
||||
"total": 2,
|
||||
"offset": 0,
|
||||
"limit": 50,
|
||||
"hasMore": false,
|
||||
"nextOffset": null
|
||||
}
|
||||
"""
|
||||
|
||||
let jobs = try GatewayConnection.decodeCronListResponse(Data(json.utf8))
|
||||
|
||||
#expect(jobs.count == 1)
|
||||
#expect(jobs.first?.id == "good")
|
||||
}
|
||||
|
||||
@Test func `decode cron runs response skips malformed entries`() throws {
|
||||
let json = """
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"ts": 1,
|
||||
"jobId": "good",
|
||||
"action": "finished",
|
||||
"status": "ok"
|
||||
},
|
||||
{
|
||||
"jobId": "bad",
|
||||
"action": "finished",
|
||||
"status": "ok"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
let entries = try GatewayConnection.decodeCronRunsResponse(Data(json.utf8))
|
||||
|
||||
#expect(entries.count == 1)
|
||||
#expect(entries.first?.jobId == "good")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct DashboardWindowSmokeTests {
|
||||
@Test func `dashboard window controller shows and closes`() throws {
|
||||
let url = try #require(URL(string: "http://127.0.0.1:18789/control/#token=device-token"))
|
||||
let controller = DashboardWindowController(
|
||||
url: url,
|
||||
auth: DashboardWindowAuth(
|
||||
gatewayUrl: "ws://127.0.0.1:18789/control/",
|
||||
token: "device-token",
|
||||
password: nil))
|
||||
controller.show()
|
||||
#expect(controller.window?.styleMask.contains(.titled) == true)
|
||||
#expect(controller.window?.styleMask.contains(.closable) == true)
|
||||
#expect(controller.window?.contentViewController != nil)
|
||||
#expect(controller.window?.standardWindowButton(.closeButton) != nil)
|
||||
#expect((controller.window?.frame.width ?? 0) >= DashboardWindowLayout.windowMinSize.width)
|
||||
#expect((controller.window?.frame.height ?? 0) >= DashboardWindowLayout.windowMinSize.height)
|
||||
controller.closeDashboard()
|
||||
}
|
||||
|
||||
@Test func `dashboard navigation stays on same endpoint`() throws {
|
||||
let dashboard = try #require(URL(string: "http://127.0.0.1:18789/control/"))
|
||||
#expect(DashboardWindowController.shouldAllowNavigation(
|
||||
to: try #require(URL(string: "http://127.0.0.1:18789/control/chat")),
|
||||
dashboardURL: dashboard))
|
||||
#expect(!DashboardWindowController.shouldAllowNavigation(
|
||||
to: try #require(URL(string: "https://docs.openclaw.ai/")),
|
||||
dashboardURL: dashboard))
|
||||
}
|
||||
|
||||
@Test func `dashboard origin brackets ipv6 literals`() throws {
|
||||
let url = try #require(URL(string: "http://[fd12:3456:789a::1]:18789/control/"))
|
||||
#expect(DashboardWindowController.originString(for: url) == "http://[fd12:3456:789a::1]:18789")
|
||||
}
|
||||
|
||||
@Test func `dashboard failure state opens in dashboard window`() throws {
|
||||
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
|
||||
let controller = DashboardWindowController(
|
||||
url: url,
|
||||
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
|
||||
controller.showFailure(
|
||||
title: "Dashboard unavailable",
|
||||
message: "Remote control tunnel failed",
|
||||
detail: "Reset the remote tunnel and try again.")
|
||||
#expect(controller.window?.isVisible == true)
|
||||
#expect(controller.window?.styleMask.contains(.closable) == true)
|
||||
controller.closeDashboard()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct DeepLinkAgentPolicyTests {
|
||||
@Test func `validate message for handle rejects too long when unkeyed`() {
|
||||
let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1)
|
||||
let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: false)
|
||||
switch res {
|
||||
case let .failure(error):
|
||||
#expect(
|
||||
error == .messageTooLongForConfirmation(
|
||||
max: DeepLinkAgentPolicy.maxUnkeyedConfirmChars,
|
||||
actual: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1))
|
||||
case .success:
|
||||
Issue.record("expected failure, got success")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `validate message for handle allows too long when keyed`() {
|
||||
let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1)
|
||||
let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: true)
|
||||
switch res {
|
||||
case .success:
|
||||
break
|
||||
case let .failure(error):
|
||||
Issue.record("expected success, got failure: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `effective delivery ignores delivery fields when unkeyed`() {
|
||||
let link = AgentDeepLink(
|
||||
message: "Hello",
|
||||
sessionKey: "s",
|
||||
thinking: "low",
|
||||
deliver: true,
|
||||
to: "+15551234567",
|
||||
channel: "whatsapp",
|
||||
timeoutSeconds: 10,
|
||||
key: nil)
|
||||
let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: false)
|
||||
#expect(res.deliver == false)
|
||||
#expect(res.to == nil)
|
||||
#expect(res.channel == .last)
|
||||
}
|
||||
|
||||
@Test func `effective delivery honors deliver for deliverable channels when keyed`() {
|
||||
let link = AgentDeepLink(
|
||||
message: "Hello",
|
||||
sessionKey: "s",
|
||||
thinking: "low",
|
||||
deliver: true,
|
||||
to: " +15551234567 ",
|
||||
channel: "whatsapp",
|
||||
timeoutSeconds: 10,
|
||||
key: "secret")
|
||||
let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true)
|
||||
#expect(res.deliver == true)
|
||||
#expect(res.to == "+15551234567")
|
||||
#expect(res.channel == .whatsapp)
|
||||
}
|
||||
|
||||
@Test func `effective delivery still blocks web chat delivery when keyed`() {
|
||||
let link = AgentDeepLink(
|
||||
message: "Hello",
|
||||
sessionKey: "s",
|
||||
thinking: "low",
|
||||
deliver: true,
|
||||
to: "+15551234567",
|
||||
channel: "webchat",
|
||||
timeoutSeconds: 10,
|
||||
key: "secret")
|
||||
let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true)
|
||||
#expect(res.deliver == false)
|
||||
#expect(res.channel == .webchat)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct DeviceModelCatalogTests {
|
||||
@Test
|
||||
func `symbol prefers model identifier prefixes`() {
|
||||
#expect(DeviceModelCatalog
|
||||
.symbol(deviceFamily: "iPad", modelIdentifier: "iPad16,6", friendlyName: nil) == "ipad")
|
||||
#expect(DeviceModelCatalog
|
||||
.symbol(deviceFamily: "iPhone", modelIdentifier: "iPhone17,3", friendlyName: nil) == "iphone")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `symbol uses friendly name for mac variants`() {
|
||||
#expect(DeviceModelCatalog.symbol(
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "Mac99,1",
|
||||
friendlyName: "Mac Studio (2025)") == "macstudio")
|
||||
#expect(DeviceModelCatalog.symbol(
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "Mac99,2",
|
||||
friendlyName: "Mac mini (2024)") == "macmini")
|
||||
#expect(DeviceModelCatalog.symbol(
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "Mac99,3",
|
||||
friendlyName: "MacBook Pro (14-inch, 2024)") == "laptopcomputer")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `symbol falls back to device family`() {
|
||||
#expect(DeviceModelCatalog.symbol(deviceFamily: "Android", modelIdentifier: "", friendlyName: nil) == "android")
|
||||
#expect(DeviceModelCatalog.symbol(deviceFamily: "Linux", modelIdentifier: "", friendlyName: nil) == "cpu")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `presentation uses bundled model mappings`() {
|
||||
let presentation = DeviceModelCatalog.presentation(deviceFamily: "iPhone", modelIdentifier: "iPhone1,1")
|
||||
#expect(presentation?.title == "iPhone")
|
||||
}
|
||||
}
|
||||
521
apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift
Normal file
521
apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift
Normal file
@@ -0,0 +1,521 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
/// These cases cover optional `security=allowlist` behavior.
|
||||
/// Default install posture remains deny-by-default for exec on macOS node-host.
|
||||
struct ExecAllowlistTests {
|
||||
private struct ShellParserParityFixture: Decodable {
|
||||
struct Case: Decodable {
|
||||
let id: String
|
||||
let command: String
|
||||
let ok: Bool
|
||||
let executables: [String]
|
||||
}
|
||||
|
||||
let cases: [Case]
|
||||
}
|
||||
|
||||
private struct WrapperResolutionParityFixture: Decodable {
|
||||
struct Case: Decodable {
|
||||
let id: String
|
||||
let argv: [String]
|
||||
let expectedRawExecutable: String?
|
||||
}
|
||||
|
||||
let cases: [Case]
|
||||
}
|
||||
|
||||
private static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] {
|
||||
let fixtureURL = self.fixtureURL(filename: "exec-allowlist-shell-parser-parity.json")
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data)
|
||||
return fixture.cases
|
||||
}
|
||||
|
||||
private static func loadWrapperResolutionParityCases() throws -> [WrapperResolutionParityFixture.Case] {
|
||||
let fixtureURL = self.fixtureURL(filename: "exec-wrapper-resolution-parity.json")
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
let fixture = try JSONDecoder().decode(WrapperResolutionParityFixture.self, from: data)
|
||||
return fixture.cases
|
||||
}
|
||||
|
||||
private static func fixtureURL(filename: String) -> URL {
|
||||
var repoRoot = URL(fileURLWithPath: #filePath)
|
||||
for _ in 0..<5 {
|
||||
repoRoot.deleteLastPathComponent()
|
||||
}
|
||||
return repoRoot
|
||||
.appendingPathComponent("test")
|
||||
.appendingPathComponent("fixtures")
|
||||
.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
private static func homebrewRGResolution() -> ExecCommandResolution {
|
||||
ExecCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
}
|
||||
|
||||
@Test func `match uses resolved path`() {
|
||||
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
|
||||
let resolution = Self.homebrewRGResolution()
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func `match accepts basename pattern for PATH resolved executable`() {
|
||||
let entry = ExecAllowlistEntry(pattern: "rg")
|
||||
let resolution = Self.homebrewRGResolution()
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func `match accepts basename glob for PATH resolved executable`() {
|
||||
let entry = ExecAllowlistEntry(pattern: "r?")
|
||||
let resolution = Self.homebrewRGResolution()
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func `match ignores basename for path selected executable`() {
|
||||
let entry = ExecAllowlistEntry(pattern: "echo")
|
||||
let relativeResolution = ExecCommandResolution(
|
||||
rawExecutable: "./echo",
|
||||
resolvedPath: "/tmp/oc-basename/echo",
|
||||
executableName: "echo",
|
||||
cwd: "/tmp/oc-basename")
|
||||
let absoluteResolution = ExecCommandResolution(
|
||||
rawExecutable: "/tmp/oc-basename/echo",
|
||||
resolvedPath: "/tmp/oc-basename/echo",
|
||||
executableName: "echo",
|
||||
cwd: "/tmp/oc-basename")
|
||||
#expect(ExecAllowlistMatcher.match(entries: [entry], resolution: relativeResolution) == nil)
|
||||
#expect(ExecAllowlistMatcher.match(entries: [entry], resolution: absoluteResolution) == nil)
|
||||
}
|
||||
|
||||
@Test func `match is case insensitive`() {
|
||||
let entry = ExecAllowlistEntry(pattern: "/OPT/HOMEBREW/BIN/RG")
|
||||
let resolution = Self.homebrewRGResolution()
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func `match supports glob star`() {
|
||||
let entry = ExecAllowlistEntry(pattern: "/opt/**/rg")
|
||||
let resolution = Self.homebrewRGResolution()
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist splits shell chains`() {
|
||||
let command = ["/bin/sh", "-c", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 2)
|
||||
#expect(resolutions[0].executableName == "echo")
|
||||
#expect(resolutions[1].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist splits posix combined c flag payloads`() {
|
||||
for command in [
|
||||
["/bin/bash", "-xc", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-ec", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-euxc", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-cx", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-O", "extglob", "-xc", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-co", "vi", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-oc", "vi", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-cO", "extglob", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-xo", "vi", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-xO", "extglob", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "+xo", "vi", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "--rcfile", "/tmp/rc", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "--init-file=/tmp/rc", "-c", "/usr/bin/printf safe_marker"],
|
||||
] {
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(resolutions[0].executableName == "printf")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist treats c after posix shell operand as direct exec`() {
|
||||
for command in [
|
||||
["/bin/bash", "./script.sh", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-x", "-C", "echo ok", "-c", "/usr/bin/printf safe_marker"],
|
||||
] {
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: "/tmp",
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/bin/bash")
|
||||
#expect(resolutions[0].executableName == "bash")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed for interactive posix shell wrappers`() {
|
||||
for command in [
|
||||
["/bin/bash", "-i", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-ic", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "--rcfile", "/tmp/payload.sh", "-i", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/usr/bin/fish", "--interactive", "-c", "/usr/bin/printf safe_marker"],
|
||||
] {
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed for login shell wrappers`() {
|
||||
for command in [
|
||||
["/bin/bash", "-l", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "--login", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "-xlc", "/usr/bin/printf safe_marker"],
|
||||
["/bin/dash", "-lc", "/usr/bin/printf safe_marker"],
|
||||
["ash", "-lc", "/usr/bin/printf safe_marker"],
|
||||
["/usr/bin/fish", "-l", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/usr/bin/fish", "--login", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/bin/sh", "-lc", "/usr/bin/printf safe_marker"],
|
||||
["/bin/sh", "-x", "-lc", "/usr/bin/printf safe_marker"],
|
||||
["/usr/bin/env", "/bin/sh", "-lc", "/usr/bin/printf safe_marker"],
|
||||
] {
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed for fish init command wrappers`() {
|
||||
for command in [
|
||||
["/usr/bin/fish", "--init-command=/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/usr/bin/fish", "--init-command", "/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/usr/bin/fish", "-C", "/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/usr/bin/fish", "-C/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/usr/bin/fish", "--init-command", "-c; /tmp/payload.fish", "/usr/bin/printf safe_marker"],
|
||||
["/usr/bin/fish", "-C", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/usr/bin/fish", "-c/tmp/payload.fish", "/usr/bin/printf safe_marker"],
|
||||
] {
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist uses wrapper argv payload even with canonical raw command`() {
|
||||
let command = ["/bin/sh", "-c", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let canonicalRaw = "/bin/sh -c \"echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test\""
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: canonicalRaw,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 2)
|
||||
#expect(resolutions[0].executableName == "echo")
|
||||
#expect(resolutions[1].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist preserves generated sh lc raw payload binding`() {
|
||||
let command = ["/bin/sh", "-lc", "/usr/bin/printf safe_marker"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "/usr/bin/printf safe_marker",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(resolutions[0].executableName == "printf")
|
||||
|
||||
let rawlessResolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(rawlessResolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed for env modified shell wrappers`() {
|
||||
let command = ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo allowlisted"]
|
||||
let canonicalRaw = "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo allowlisted\""
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: canonicalRaw,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed for env dash shell wrappers`() {
|
||||
let command = ["/usr/bin/env", "-", "bash", "-lc", "echo allowlisted"]
|
||||
let canonicalRaw = "/usr/bin/env - bash -lc \"echo allowlisted\""
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: canonicalRaw,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist keeps quoted operators in single segment`() {
|
||||
let command = ["/bin/sh", "-c", "echo \"a && b\""]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo \"a && b\"",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].executableName == "echo")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed on command substitution`() {
|
||||
let command = ["/bin/sh", "-c", "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed on quoted command substitution`() {
|
||||
let command = ["/bin/sh", "-c", "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\""]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\"",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed on line-continued command substitution`() {
|
||||
let command = ["/bin/sh", "-c", "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed on chained line-continued command substitution`() {
|
||||
let command = [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)",
|
||||
]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed on quoted backticks`() {
|
||||
let command = ["/bin/sh", "-c", "echo \"ok `/usr/bin/id`\""]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo \"ok `/usr/bin/id`\"",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist matches shared shell parser fixture`() throws {
|
||||
let fixtures = try Self.loadShellParserParityCases()
|
||||
for fixture in fixtures {
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: ["/bin/sh", "-c", fixture.command],
|
||||
rawCommand: fixture.command,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
|
||||
#expect(!resolutions.isEmpty == fixture.ok)
|
||||
if fixture.ok {
|
||||
let executables = resolutions.map { $0.executableName.lowercased() }
|
||||
let expected = fixture.executables.map { $0.lowercased() }
|
||||
#expect(executables == expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `resolve matches shared wrapper resolution fixture`() throws {
|
||||
let fixtures = try Self.loadWrapperResolutionParityCases()
|
||||
for fixture in fixtures {
|
||||
let resolution = ExecCommandResolution.resolve(
|
||||
command: fixture.argv,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolution?.rawExecutable == fixture.expectedRawExecutable)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `resolve keeps env dash wrapper as effective executable`() {
|
||||
let resolution = ExecCommandResolution.resolve(
|
||||
command: ["/usr/bin/env", "-", "/usr/bin/printf", "ok"],
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolution?.rawExecutable == "/usr/bin/env")
|
||||
#expect(resolution?.resolvedPath == "/usr/bin/env")
|
||||
#expect(resolution?.executableName == "env")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist treats plain sh invocation as direct exec`() {
|
||||
let command = ["/bin/sh", "./script.sh"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: "/tmp",
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].executableName == "sh")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env shell wrapper chains`() {
|
||||
let command = [
|
||||
"/usr/bin/env",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test",
|
||||
]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 2)
|
||||
#expect(resolutions[0].executableName == "echo")
|
||||
#expect(resolutions[1].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env dispatch wrappers inside shell segments`() {
|
||||
let command = ["/bin/sh", "-c", "env /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "env /usr/bin/touch /tmp/openclaw-allowlist-test",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
|
||||
#expect(resolutions[0].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist preserves env assignments inside shell segments`() {
|
||||
let command = ["/bin/sh", "-c", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||
#expect(resolutions[0].executableName == "env")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
|
||||
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||
#expect(resolutions[0].executableName == "env")
|
||||
}
|
||||
|
||||
@Test func `approval evaluator resolves shell payload from canonical wrapper text`() async {
|
||||
let command = ["/bin/sh", "-c", "/usr/bin/printf ok"]
|
||||
let rawCommand = "/bin/sh -c \"/usr/bin/printf ok\""
|
||||
let evaluation = await ExecApprovalEvaluator.evaluate(
|
||||
command: command,
|
||||
rawCommand: rawCommand,
|
||||
cwd: nil,
|
||||
envOverrides: ["PATH": "/usr/bin:/bin"],
|
||||
agentId: nil)
|
||||
|
||||
#expect(evaluation.displayCommand == rawCommand)
|
||||
#expect(evaluation.allowlistResolutions.count == 1)
|
||||
#expect(evaluation.allowlistResolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
|
||||
}
|
||||
|
||||
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns == ["/usr/bin/printf"])
|
||||
}
|
||||
|
||||
@Test func `allow always patterns fail closed for env modified shell wrappers`() {
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: [
|
||||
"/usr/bin/env",
|
||||
"BASH_ENV=/tmp/payload.sh",
|
||||
"/bin/sh",
|
||||
"-lc",
|
||||
"/usr/bin/printf ok",
|
||||
],
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"],
|
||||
rawCommand: "/usr/bin/printf ok")
|
||||
|
||||
#expect(patterns.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `allow always patterns preserve generated sh lc raw payload binding`() {
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["/bin/sh", "-lc", "/usr/bin/printf safe_marker"],
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"],
|
||||
rawCommand: "/usr/bin/printf safe_marker")
|
||||
|
||||
#expect(patterns == ["/usr/bin/printf"])
|
||||
}
|
||||
|
||||
@Test func `match all requires every segment to match`() {
|
||||
let first = ExecCommandResolution(
|
||||
rawExecutable: "echo",
|
||||
resolvedPath: "/usr/bin/echo",
|
||||
executableName: "echo",
|
||||
cwd: nil)
|
||||
let second = ExecCommandResolution(
|
||||
rawExecutable: "/usr/bin/touch",
|
||||
resolvedPath: "/usr/bin/touch",
|
||||
executableName: "touch",
|
||||
cwd: nil)
|
||||
let resolutions = [first, second]
|
||||
|
||||
let partial = ExecAllowlistMatcher.matchAll(
|
||||
entries: [ExecAllowlistEntry(pattern: "/usr/bin/echo")],
|
||||
resolutions: resolutions)
|
||||
#expect(partial.isEmpty)
|
||||
|
||||
let full = ExecAllowlistMatcher.matchAll(
|
||||
entries: [ExecAllowlistEntry(pattern: "/USR/BIN/ECHO"), ExecAllowlistEntry(pattern: "/usr/bin/touch")],
|
||||
resolutions: resolutions)
|
||||
#expect(full.count == 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct ExecApprovalCommandDisplaySanitizerTests {
|
||||
@Test func `escapes invisible command spoofing characters`() {
|
||||
let input = "date\u{200B}\u{3164}\u{FFA0}\u{115F}\u{1160}가"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(input) ==
|
||||
"date\\u{200B}\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가")
|
||||
}
|
||||
|
||||
@Test func `escapes control characters used to spoof line breaks`() {
|
||||
let input = "echo safe\n\rcurl https://example.test"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(input) ==
|
||||
"echo safe\\u{A}\\u{D}curl https://example.test")
|
||||
}
|
||||
|
||||
@Test func `escapes Unicode line and paragraph separators`() {
|
||||
let lineInput = "echo ok\u{2028}curl https://example.test"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(lineInput) ==
|
||||
"echo ok\\u{2028}curl https://example.test")
|
||||
let paragraphInput = "echo ok\u{2029}curl https://example.test"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(paragraphInput) ==
|
||||
"echo ok\\u{2029}curl https://example.test")
|
||||
}
|
||||
|
||||
@Test func `escapes non-ASCII Unicode space separators while preserving ASCII space`() {
|
||||
let nbspInput = "echo ok\u{00A0}curl"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(nbspInput) == "echo ok\\u{A0}curl")
|
||||
let narrowNbspInput = "echo ok\u{202F}curl"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(narrowNbspInput) == "echo ok\\u{202F}curl")
|
||||
let ideographicSpaceInput = "echo ok\u{3000}curl"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(ideographicSpaceInput) ==
|
||||
"echo ok\\u{3000}curl")
|
||||
let asciiSpaceInput = "echo ok curl"
|
||||
#expect(ExecApprovalCommandDisplaySanitizer.sanitize(asciiSpaceInput) == "echo ok curl")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct ExecApprovalHelpersTests {
|
||||
@Test func `parse decision trims and rejects invalid`() {
|
||||
#expect(ExecApprovalHelpers.parseDecision("allow-once") == .allowOnce)
|
||||
#expect(ExecApprovalHelpers.parseDecision(" allow-always ") == .allowAlways)
|
||||
#expect(ExecApprovalHelpers.parseDecision("deny") == .deny)
|
||||
#expect(ExecApprovalHelpers.parseDecision("") == nil)
|
||||
#expect(ExecApprovalHelpers.parseDecision("nope") == nil)
|
||||
}
|
||||
|
||||
@Test func `allowlist pattern prefers resolution`() {
|
||||
let resolved = ExecCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: resolved) == resolved.resolvedPath)
|
||||
|
||||
let rawOnly = ExecCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: nil,
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: rawOnly) == "rg")
|
||||
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: nil) == "rg")
|
||||
#expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil)
|
||||
}
|
||||
|
||||
@Test func `validate allowlist pattern returns reasons`() {
|
||||
#expect(ExecApprovalHelpers.isPathPattern("/usr/bin/rg"))
|
||||
#expect(ExecApprovalHelpers.isPathPattern(" ~/bin/rg "))
|
||||
#expect(!ExecApprovalHelpers.isPathPattern("rg"))
|
||||
#expect(ExecApprovalHelpers.isValidAllowlistPattern("rg"))
|
||||
|
||||
if case let .invalid(reason) = ExecApprovalHelpers.validateAllowlistPattern(" ") {
|
||||
#expect(reason == .empty)
|
||||
} else {
|
||||
Issue.record("Expected empty pattern rejection")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `requires ask matches policy`() {
|
||||
let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil)
|
||||
#expect(ExecApprovalHelpers.requiresAsk(
|
||||
ask: .always,
|
||||
security: .deny,
|
||||
allowlistMatch: nil,
|
||||
skillAllow: false))
|
||||
#expect(ExecApprovalHelpers.requiresAsk(
|
||||
ask: .onMiss,
|
||||
security: .allowlist,
|
||||
allowlistMatch: nil,
|
||||
skillAllow: false))
|
||||
#expect(!ExecApprovalHelpers.requiresAsk(
|
||||
ask: .onMiss,
|
||||
security: .allowlist,
|
||||
allowlistMatch: entry,
|
||||
skillAllow: false))
|
||||
#expect(!ExecApprovalHelpers.requiresAsk(
|
||||
ask: .onMiss,
|
||||
security: .allowlist,
|
||||
allowlistMatch: nil,
|
||||
skillAllow: true))
|
||||
#expect(!ExecApprovalHelpers.requiresAsk(
|
||||
ask: .off,
|
||||
security: .allowlist,
|
||||
allowlistMatch: nil,
|
||||
skillAllow: false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct ExecApprovalPromptLayoutTests {
|
||||
@Test func `allowed decisions omit durable approval even when ask allows it`() {
|
||||
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
|
||||
ExecApprovalPromptRequest(
|
||||
command: "/bin/sh -lc pwd",
|
||||
cwd: "/Users/example/projects/openclaw",
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "on-miss",
|
||||
agentId: "main",
|
||||
resolvedPath: "/bin/sh",
|
||||
sessionKey: "session-1",
|
||||
allowedDecisions: [.allowOnce, .deny]))
|
||||
|
||||
#expect(decisions == [.allowOnce, .deny])
|
||||
}
|
||||
|
||||
@Test func `ask always prompts omit durable approval when decisions are omitted`() {
|
||||
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
|
||||
ExecApprovalPromptRequest(
|
||||
command: "/bin/sh -lc pwd",
|
||||
cwd: "/Users/example/projects/openclaw",
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "always",
|
||||
agentId: "main",
|
||||
resolvedPath: "/bin/sh",
|
||||
sessionKey: "session-1"))
|
||||
|
||||
#expect(decisions == [.allowOnce, .deny])
|
||||
}
|
||||
|
||||
@Test func `ask on miss prompts keep durable approval when decisions are omitted`() {
|
||||
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
|
||||
ExecApprovalPromptRequest(
|
||||
command: "/bin/sh -lc pwd",
|
||||
cwd: "/Users/example/projects/openclaw",
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "on-miss",
|
||||
agentId: "main",
|
||||
resolvedPath: "/bin/sh",
|
||||
sessionKey: "session-1"))
|
||||
|
||||
#expect(decisions == [.allowOnce, .allowAlways, .deny])
|
||||
}
|
||||
|
||||
@Test func `legacy prompts keep durable approval when policy fields are omitted`() {
|
||||
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
|
||||
ExecApprovalPromptRequest(
|
||||
command: "/bin/sh -lc pwd",
|
||||
cwd: "/Users/example/projects/openclaw",
|
||||
host: "node",
|
||||
security: "full",
|
||||
agentId: "main",
|
||||
resolvedPath: "/bin/sh",
|
||||
sessionKey: "session-1"))
|
||||
|
||||
#expect(decisions == [.allowOnce, .allowAlways, .deny])
|
||||
}
|
||||
|
||||
@Test func `unknown ask prompts keep legacy durable approval when decisions are omitted`() {
|
||||
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
|
||||
ExecApprovalPromptRequest(
|
||||
command: "/bin/sh -lc pwd",
|
||||
cwd: "/Users/example/projects/openclaw",
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "unexpected",
|
||||
agentId: "main",
|
||||
resolvedPath: "/bin/sh",
|
||||
sessionKey: "session-1"))
|
||||
|
||||
#expect(decisions == [.allowOnce, .allowAlways, .deny])
|
||||
}
|
||||
|
||||
@Test func `approval request decodes valid allowed decisions only`() throws {
|
||||
let data = """
|
||||
{
|
||||
"command": "/bin/sh -lc pwd",
|
||||
"ask": "on-miss",
|
||||
"allowedDecisions": ["allow-once", "bad", "deny", 3]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let request = try JSONDecoder().decode(ExecApprovalPromptRequest.self, from: data)
|
||||
|
||||
#expect(request.allowedDecisions == [.allowOnce, .deny])
|
||||
}
|
||||
|
||||
@Test func `approval request falls back when allowed decisions has wrong shape`() throws {
|
||||
let data = """
|
||||
{
|
||||
"command": "/bin/sh -lc pwd",
|
||||
"ask": "always",
|
||||
"allowedDecisions": "allow-once"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let request = try JSONDecoder().decode(ExecApprovalPromptRequest.self, from: data)
|
||||
|
||||
#expect(ExecApprovalsPromptPresenter.allowedPromptDecisions(request) == [.allowOnce, .deny])
|
||||
}
|
||||
|
||||
@Test func `modal close does not synthesize deny when deny is unavailable`() {
|
||||
let closeResponse = NSApplication.ModalResponse(rawValue: 0)
|
||||
|
||||
let withoutDeny = ExecApprovalsPromptPresenter.decision(
|
||||
forModalResponse: closeResponse,
|
||||
decisions: [.allowOnce])
|
||||
let withDeny = ExecApprovalsPromptPresenter.decision(
|
||||
forModalResponse: closeResponse,
|
||||
decisions: [.allowOnce, .deny])
|
||||
|
||||
#expect(withoutDeny == nil)
|
||||
#expect(withDeny == .deny)
|
||||
}
|
||||
|
||||
@Test func `accessory view reserves nonzero alert layout space`() {
|
||||
let accessory = ExecApprovalsPromptPresenter.buildAccessoryView(
|
||||
ExecApprovalPromptRequest(
|
||||
command: "/bin/sh -lc \"hostname; uptime; echo '---'\"",
|
||||
cwd: "/Users/example/projects/openclaw",
|
||||
host: "node",
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
agentId: "main",
|
||||
resolvedPath: "/bin/sh",
|
||||
sessionKey: "session-1"))
|
||||
|
||||
#expect(accessory.frame.width >= 380)
|
||||
#expect(accessory.frame.height >= 160)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Allow this command?"
|
||||
alert.informativeText = "Review the command details before allowing."
|
||||
alert.accessoryView = accessory
|
||||
|
||||
#expect(alert.accessoryView?.frame.width == accessory.frame.width)
|
||||
#expect(alert.accessoryView?.frame.height == accessory.frame.height)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
struct ExecApprovalsGatewayPrompterTests {
|
||||
@Test func `session match prefers active session`() {
|
||||
let matches = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||
mode: .remote,
|
||||
activeSession: " main ",
|
||||
requestSession: "main",
|
||||
lastInputSeconds: nil)
|
||||
#expect(matches)
|
||||
|
||||
let mismatched = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||
mode: .remote,
|
||||
activeSession: "other",
|
||||
requestSession: "main",
|
||||
lastInputSeconds: 0)
|
||||
#expect(!mismatched)
|
||||
}
|
||||
|
||||
@Test func `session fallback uses recent activity`() {
|
||||
let recent = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||
mode: .remote,
|
||||
activeSession: nil,
|
||||
requestSession: "main",
|
||||
lastInputSeconds: 10,
|
||||
thresholdSeconds: 120)
|
||||
#expect(recent)
|
||||
|
||||
let stale = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||
mode: .remote,
|
||||
activeSession: nil,
|
||||
requestSession: "main",
|
||||
lastInputSeconds: 200,
|
||||
thresholdSeconds: 120)
|
||||
#expect(!stale)
|
||||
}
|
||||
|
||||
@Test func `default behavior matches mode`() {
|
||||
let local = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||
mode: .local,
|
||||
activeSession: nil,
|
||||
requestSession: nil,
|
||||
lastInputSeconds: 400)
|
||||
#expect(local)
|
||||
|
||||
let remote = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||
mode: .remote,
|
||||
activeSession: nil,
|
||||
requestSession: nil,
|
||||
lastInputSeconds: 400)
|
||||
#expect(!remote)
|
||||
}
|
||||
|
||||
// MARK: - shouldAsk
|
||||
|
||||
@Test func `ask always prompts regardless of security`() {
|
||||
#expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .always))
|
||||
#expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .always))
|
||||
#expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .always))
|
||||
}
|
||||
|
||||
@Test func `ask on miss prompts only for allowlist`() {
|
||||
#expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .onMiss))
|
||||
#expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .onMiss))
|
||||
#expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .onMiss))
|
||||
}
|
||||
|
||||
@Test func `ask off never prompts`() {
|
||||
#expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .off))
|
||||
#expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .off))
|
||||
#expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .off))
|
||||
}
|
||||
|
||||
@Test func `fallback allowlist allows matching resolved path`() {
|
||||
let decision = ExecApprovalsGatewayPrompter._testFallbackDecision(
|
||||
command: "git status",
|
||||
resolvedPath: "/usr/bin/git",
|
||||
askFallback: .allowlist,
|
||||
allowlistPatterns: ["/usr/bin/git"])
|
||||
#expect(decision == .allowOnce)
|
||||
}
|
||||
|
||||
@Test func `fallback allowlist denies allowlist miss`() {
|
||||
let decision = ExecApprovalsGatewayPrompter._testFallbackDecision(
|
||||
command: "git status",
|
||||
resolvedPath: "/usr/bin/git",
|
||||
askFallback: .allowlist,
|
||||
allowlistPatterns: ["/usr/bin/rg"])
|
||||
#expect(decision == .deny)
|
||||
}
|
||||
|
||||
@Test func `fallback full allows when prompt cannot be shown`() {
|
||||
let decision = ExecApprovalsGatewayPrompter._testFallbackDecision(
|
||||
command: "git status",
|
||||
resolvedPath: "/usr/bin/git",
|
||||
askFallback: .full,
|
||||
allowlistPatterns: [])
|
||||
#expect(decision == .allowOnce)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct ExecApprovalsSocketAuthTests {
|
||||
@Test
|
||||
func `timing safe hex compare matches equal strings`() {
|
||||
#expect(timingSafeHexStringEquals(String(repeating: "a", count: 64), String(repeating: "a", count: 64)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `timing safe hex compare rejects mismatched strings`() {
|
||||
let expected = String(repeating: "a", count: 63) + "b"
|
||||
let provided = String(repeating: "a", count: 63) + "c"
|
||||
#expect(!timingSafeHexStringEquals(expected, provided))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `timing safe hex compare rejects different length strings`() {
|
||||
#expect(!timingSafeHexStringEquals(String(repeating: "a", count: 64), "deadbeef"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `exec host limiter preserves small output`() {
|
||||
#expect(ExecHostOutputLimiter.truncate("hello") == "hello")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `exec host limiter preserves a valid utf8 tail`() {
|
||||
let input = String(repeating: "x", count: 2 * 1024 * 1024) + "✅"
|
||||
let limited = ExecHostOutputLimiter.truncate(input)
|
||||
|
||||
#expect(limited.hasPrefix("... (truncated) "))
|
||||
#expect(limited.hasSuffix("✅"))
|
||||
#expect(limited.utf8.count <= ExecHostOutputLimiter.maxOutputFieldBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `exec host limiter keeps escaped output below the jsonl cap`() throws {
|
||||
let escaped = String(repeating: "\u{0}", count: 2 * 1024 * 1024)
|
||||
let limited = ExecHostOutputLimiter.truncate(escaped)
|
||||
let response = EncodedExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: "test",
|
||||
ok: true,
|
||||
payload: EncodedExecHostRunResult(
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
success: true,
|
||||
stdout: limited,
|
||||
stderr: limited,
|
||||
error: nil),
|
||||
error: nil)
|
||||
|
||||
#expect(try JSONEncoder().encode(response).count < ExecHostOutputLimiter.maxJsonlResponseBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `exec host limiter bounds real command output`() async throws {
|
||||
let result = await ShellExecutor.runDetailed(
|
||||
command: [
|
||||
"/usr/bin/perl",
|
||||
"-e",
|
||||
"print 'x' x (2 * 1024 * 1024); print STDERR 'y' x (2 * 1024 * 1024);",
|
||||
],
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 10)
|
||||
|
||||
#expect(ExecHostOutputLimiter.truncate(result.stdout).utf8.count <= ExecHostOutputLimiter.maxOutputFieldBytes)
|
||||
#expect(ExecHostOutputLimiter.truncate(result.stderr).utf8.count <= ExecHostOutputLimiter.maxOutputFieldBytes)
|
||||
#expect(result.exitCode == 0)
|
||||
}
|
||||
|
||||
private struct EncodedExecHostResponse: Codable {
|
||||
var type: String
|
||||
var id: String
|
||||
var ok: Bool
|
||||
var payload: EncodedExecHostRunResult?
|
||||
var error: String?
|
||||
}
|
||||
|
||||
private struct EncodedExecHostRunResult: Codable {
|
||||
var exitCode: Int?
|
||||
var timedOut: Bool
|
||||
var success: Bool
|
||||
var stdout: String
|
||||
var stderr: String
|
||||
var error: String?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ExecApprovalsSocketPathGuardTests {
|
||||
@Test
|
||||
func `harden parent directory creates directory with0700 permissions`() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-socket-guard-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
let socketPath = root
|
||||
.appendingPathComponent("nested", isDirectory: true)
|
||||
.appendingPathComponent("exec-approvals.sock", isDirectory: false)
|
||||
.path
|
||||
|
||||
try ExecApprovalsSocketPathGuard.hardenParentDirectory(for: socketPath)
|
||||
|
||||
let parent = URL(fileURLWithPath: socketPath).deletingLastPathComponent()
|
||||
#expect(FileManager().fileExists(atPath: parent.path))
|
||||
let attrs = try FileManager().attributesOfItem(atPath: parent.path)
|
||||
let permissions = (attrs[.posixPermissions] as? NSNumber)?.intValue ?? -1
|
||||
#expect(permissions & 0o777 == 0o700)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `remove existing socket rejects symlink path`() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-socket-guard-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
let target = root.appendingPathComponent("target.txt")
|
||||
_ = FileManager().createFile(atPath: target.path, contents: Data("x".utf8))
|
||||
let symlink = root.appendingPathComponent("exec-approvals.sock")
|
||||
try FileManager().createSymbolicLink(at: symlink, withDestinationURL: target)
|
||||
|
||||
do {
|
||||
try ExecApprovalsSocketPathGuard.removeExistingSocket(at: symlink.path)
|
||||
Issue.record("Expected symlink socket path rejection")
|
||||
} catch let error as ExecApprovalsSocketPathGuardError {
|
||||
switch error {
|
||||
case let .socketPathInvalid(path, kind):
|
||||
#expect(path == symlink.path)
|
||||
#expect(kind == .symlink)
|
||||
default:
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `remove existing socket rejects regular file path`() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-socket-guard-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
let regularFile = root.appendingPathComponent("exec-approvals.sock")
|
||||
_ = FileManager().createFile(atPath: regularFile.path, contents: Data("x".utf8))
|
||||
|
||||
do {
|
||||
try ExecApprovalsSocketPathGuard.removeExistingSocket(at: regularFile.path)
|
||||
Issue.record("Expected non-socket path rejection")
|
||||
} catch let error as ExecApprovalsSocketPathGuardError {
|
||||
switch error {
|
||||
case let .socketPathInvalid(path, kind):
|
||||
#expect(path == regularFile.path)
|
||||
#expect(kind == .other)
|
||||
default:
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ExecApprovalsStoreRefactorTests {
|
||||
private var realTemporaryDirectory: URL {
|
||||
let path = FileManager().temporaryDirectory.path
|
||||
if path.hasPrefix("/var/") {
|
||||
return URL(fileURLWithPath: "/private\(path)", isDirectory: true)
|
||||
}
|
||||
return FileManager().temporaryDirectory.resolvingSymlinksInPath()
|
||||
}
|
||||
|
||||
private func withLockedEnv(
|
||||
_ values: [String: String?],
|
||||
_ body: () async throws -> Void) async throws
|
||||
{
|
||||
func restoreEnv(_ values: [String: String?]) {
|
||||
for (key, value) in values {
|
||||
if let value {
|
||||
setenv(key, value, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await TestIsolationLock.shared.acquire()
|
||||
var previousEnv: [String: String?] = [:]
|
||||
for (key, value) in values {
|
||||
previousEnv[key] = getenv(key).map { String(cString: $0) }
|
||||
if let value {
|
||||
setenv(key, value, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await body()
|
||||
restoreEnv(previousEnv)
|
||||
await TestIsolationLock.shared.release()
|
||||
} catch {
|
||||
restoreEnv(previousEnv)
|
||||
await TestIsolationLock.shared.release()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func withTempStateDir(
|
||||
_ body: @escaping @Sendable (URL) async throws -> Void) async throws
|
||||
{
|
||||
let root = self.realTemporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let home = root.appendingPathComponent("home", isDirectory: true)
|
||||
let stateDir = root.appendingPathComponent("state", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try Self.seedCurrentApprovalsFile(in: stateDir)
|
||||
|
||||
try await self.withLockedEnv([
|
||||
"OPENCLAW_HOME": home.path,
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
]) {
|
||||
try await body(stateDir)
|
||||
}
|
||||
}
|
||||
|
||||
private func withTempHomeAndStateDir(
|
||||
_ body: @escaping @Sendable (URL, URL) async throws -> Void) async throws
|
||||
{
|
||||
let root = self.realTemporaryDirectory
|
||||
.appendingPathComponent("openclaw-home-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let home = root.appendingPathComponent("home", isDirectory: true)
|
||||
let stateDir = root.appendingPathComponent("state", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
|
||||
try await self.withLockedEnv([
|
||||
"OPENCLAW_HOME": home.path,
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
]) {
|
||||
try await body(home, stateDir)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ensure file skips rewrite when unchanged`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let url = ExecApprovalsStore.fileURL()
|
||||
let firstIdentity = try Self.fileIdentity(at: url)
|
||||
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let secondIdentity = try Self.fileIdentity(at: url)
|
||||
|
||||
#expect(firstIdentity == secondIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ensure file migrates default approvals into custom state dir`() async throws {
|
||||
try await self.withTempHomeAndStateDir { home, stateDir in
|
||||
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
try FileManager().createDirectory(
|
||||
at: legacyDir,
|
||||
withIntermediateDirectories: true)
|
||||
let legacySocket = legacyDir.appendingPathComponent("exec-approvals.sock").path
|
||||
let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json")
|
||||
let legacyJson = """
|
||||
{
|
||||
"version": 1,
|
||||
"socket": {
|
||||
"path": "\(legacySocket)",
|
||||
"token": "legacy-token"
|
||||
},
|
||||
"defaults": {
|
||||
"security": "deny",
|
||||
"ask": "always"
|
||||
},
|
||||
"agents": {
|
||||
"main": {
|
||||
"allowlist": [{ "pattern": "git status" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try Data(legacyJson.utf8).write(to: legacyFile)
|
||||
|
||||
let file = ExecApprovalsStore.ensureFile()
|
||||
let targetURL = ExecApprovalsStore.fileURL()
|
||||
|
||||
#expect(targetURL.path == stateDir.appendingPathComponent("exec-approvals.json").path)
|
||||
#expect(FileManager().fileExists(atPath: targetURL.path))
|
||||
#expect(file.socket?.path == stateDir.appendingPathComponent("exec-approvals.sock").path)
|
||||
#expect(file.socket?.token == "legacy-token")
|
||||
#expect(file.defaults?.security == .deny)
|
||||
#expect(file.defaults?.ask == .always)
|
||||
#expect(file.agents?["main"]?.allowlist?.map(\.pattern) == ["git status"])
|
||||
#expect(!FileManager().fileExists(atPath: legacyFile.path))
|
||||
#expect(FileManager().fileExists(atPath: "\(legacyFile.path).migrated"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `update allowlist accepts basename pattern`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
let rejected = ExecApprovalsStore.updateAllowlist(
|
||||
agentId: "main",
|
||||
allowlist: [
|
||||
ExecAllowlistEntry(pattern: "echo"),
|
||||
ExecAllowlistEntry(pattern: "/bin/echo"),
|
||||
])
|
||||
#expect(rejected.isEmpty)
|
||||
|
||||
let resolved = ExecApprovalsStore.resolve(agentId: "main")
|
||||
#expect(resolved.allowlist.map(\.pattern) == ["echo", "/bin/echo"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `update allowlist migrates legacy pattern from resolved path`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
let rejected = ExecApprovalsStore.updateAllowlist(
|
||||
agentId: "main",
|
||||
allowlist: [
|
||||
ExecAllowlistEntry(
|
||||
pattern: "echo",
|
||||
lastUsedAt: nil,
|
||||
lastUsedCommand: nil,
|
||||
lastResolvedPath: " /usr/bin/echo "),
|
||||
])
|
||||
#expect(rejected.isEmpty)
|
||||
|
||||
let resolved = ExecApprovalsStore.resolve(agentId: "main")
|
||||
#expect(resolved.allowlist.map(\.pattern) == ["/usr/bin/echo"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ensure file hardens state directory permissions`() async throws {
|
||||
try await self.withTempStateDir { stateDir in
|
||||
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: stateDir.path)
|
||||
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let attrs = try FileManager().attributesOfItem(atPath: stateDir.path)
|
||||
let permissions = (attrs[.posixPermissions] as? NSNumber)?.intValue ?? -1
|
||||
#expect(permissions & 0o777 == 0o700)
|
||||
}
|
||||
}
|
||||
|
||||
private static func fileIdentity(at url: URL) throws -> Int {
|
||||
let attributes = try FileManager().attributesOfItem(atPath: url.path)
|
||||
guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else {
|
||||
struct MissingIdentifierError: Error {}
|
||||
throw MissingIdentifierError()
|
||||
}
|
||||
return identifier
|
||||
}
|
||||
|
||||
private static func seedCurrentApprovalsFile(in stateDir: URL) throws {
|
||||
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
|
||||
let file = ExecApprovalsFile(
|
||||
version: 1,
|
||||
socket: ExecApprovalsSocketConfig(
|
||||
path: stateDir.appendingPathComponent("exec-approvals.sock").path,
|
||||
token: "test-token"),
|
||||
defaults: nil,
|
||||
agents: [:])
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(file)
|
||||
.write(to: stateDir.appendingPathComponent("exec-approvals.json"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct ExecHostRequestEvaluatorTests {
|
||||
@Test func `validate request rejects empty command`() {
|
||||
let request = ExecHostRequest(
|
||||
command: [],
|
||||
rawCommand: nil,
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeoutMs: nil,
|
||||
needsScreenRecording: nil,
|
||||
agentId: nil,
|
||||
sessionKey: nil,
|
||||
approvalDecision: nil)
|
||||
switch ExecHostRequestEvaluator.validateRequest(request) {
|
||||
case .success:
|
||||
Issue.record("expected invalid request")
|
||||
case let .failure(error):
|
||||
#expect(error.code == "INVALID_REQUEST")
|
||||
#expect(error.message == "command required")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `evaluate requires prompt on allowlist miss without decision`() {
|
||||
let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false)
|
||||
let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: nil)
|
||||
switch decision {
|
||||
case .requiresPrompt:
|
||||
break
|
||||
case .allow:
|
||||
Issue.record("expected prompt requirement")
|
||||
case let .deny(error):
|
||||
Issue.record("unexpected deny: \(error.message)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `evaluate allows allow once decision on allowlist miss`() {
|
||||
let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false)
|
||||
let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .allowOnce)
|
||||
switch decision {
|
||||
case let .allow(approvedByAsk):
|
||||
#expect(approvedByAsk)
|
||||
case .requiresPrompt:
|
||||
Issue.record("expected allow decision")
|
||||
case let .deny(error):
|
||||
Issue.record("unexpected deny: \(error.message)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `evaluate denies on explicit deny decision`() {
|
||||
let context = Self.makeContext(security: .full, ask: .off, allowlistSatisfied: true, skillAllow: false)
|
||||
let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .deny)
|
||||
switch decision {
|
||||
case let .deny(error):
|
||||
#expect(error.reason == "user-denied")
|
||||
case .requiresPrompt:
|
||||
Issue.record("expected deny decision")
|
||||
case .allow:
|
||||
Issue.record("expected deny decision")
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeContext(
|
||||
security: ExecSecurity,
|
||||
ask: ExecAsk,
|
||||
allowlistSatisfied: Bool,
|
||||
skillAllow: Bool) -> ExecApprovalEvaluation
|
||||
{
|
||||
ExecApprovalEvaluation(
|
||||
command: ["/usr/bin/echo", "hi"],
|
||||
displayCommand: "/usr/bin/echo hi",
|
||||
agentId: nil,
|
||||
security: security,
|
||||
ask: ask,
|
||||
env: [:],
|
||||
resolution: nil,
|
||||
allowlistResolutions: [],
|
||||
allowAlwaysPatterns: [],
|
||||
allowlistMatches: [],
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
allowlistMatch: nil,
|
||||
skillAllow: skillAllow)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct ExecSkillBinTrustTests {
|
||||
@Test func `build trust index resolves skill bin paths`() throws {
|
||||
let fixture = try Self.makeExecutable(named: "jq")
|
||||
defer { try? FileManager.default.removeItem(at: fixture.root) }
|
||||
|
||||
let trust = SkillBinsCache._testBuildTrustIndex(
|
||||
report: Self.makeReport(bins: ["jq"]),
|
||||
searchPaths: [fixture.root.path])
|
||||
|
||||
#expect(trust.names == ["jq"])
|
||||
#expect(trust.pathsByName["jq"] == [fixture.path])
|
||||
}
|
||||
|
||||
@Test func `skill auto allow accepts trusted resolved skill bin path`() throws {
|
||||
let fixture = try Self.makeExecutable(named: "jq")
|
||||
defer { try? FileManager.default.removeItem(at: fixture.root) }
|
||||
|
||||
let trust = SkillBinsCache._testBuildTrustIndex(
|
||||
report: Self.makeReport(bins: ["jq"]),
|
||||
searchPaths: [fixture.root.path])
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "jq",
|
||||
resolvedPath: fixture.path,
|
||||
executableName: "jq",
|
||||
cwd: nil)
|
||||
|
||||
#expect(ExecApprovalEvaluator._testIsSkillAutoAllowed([resolution], trustedBinsByName: trust.pathsByName))
|
||||
}
|
||||
|
||||
@Test func `skill auto allow rejects same basename at different path`() throws {
|
||||
let trusted = try Self.makeExecutable(named: "jq")
|
||||
let untrusted = try Self.makeExecutable(named: "jq")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: trusted.root)
|
||||
try? FileManager.default.removeItem(at: untrusted.root)
|
||||
}
|
||||
|
||||
let trust = SkillBinsCache._testBuildTrustIndex(
|
||||
report: Self.makeReport(bins: ["jq"]),
|
||||
searchPaths: [trusted.root.path])
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "jq",
|
||||
resolvedPath: untrusted.path,
|
||||
executableName: "jq",
|
||||
cwd: nil)
|
||||
|
||||
#expect(!ExecApprovalEvaluator._testIsSkillAutoAllowed([resolution], trustedBinsByName: trust.pathsByName))
|
||||
}
|
||||
|
||||
private static func makeExecutable(named name: String) throws -> (root: URL, path: String) {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("openclaw-skill-bin-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let file = root.appendingPathComponent(name)
|
||||
try "#!/bin/sh\nexit 0\n".write(to: file, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: NSNumber(value: Int16(0o755))],
|
||||
ofItemAtPath: file.path)
|
||||
return (root, file.path)
|
||||
}
|
||||
|
||||
private static func makeReport(bins: [String]) -> SkillsStatusReport {
|
||||
SkillsStatusReport(
|
||||
workspaceDir: "/tmp/workspace",
|
||||
managedSkillsDir: "/tmp/skills",
|
||||
skills: [
|
||||
SkillStatus(
|
||||
name: "test-skill",
|
||||
description: "test",
|
||||
source: "local",
|
||||
filePath: "/tmp/skills/test-skill/SKILL.md",
|
||||
baseDir: "/tmp/skills/test-skill",
|
||||
skillKey: "test-skill",
|
||||
primaryEnv: nil,
|
||||
emoji: nil,
|
||||
homepage: nil,
|
||||
always: false,
|
||||
disabled: false,
|
||||
eligible: true,
|
||||
requirements: SkillRequirements(bins: bins, env: [], config: []),
|
||||
missing: SkillMissing(bins: [], env: [], config: []),
|
||||
configChecks: [],
|
||||
install: []),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
private struct SystemRunCommandContractFixture: Decodable {
|
||||
let cases: [SystemRunCommandContractCase]
|
||||
}
|
||||
|
||||
private struct SystemRunCommandContractCase: Decodable {
|
||||
let name: String
|
||||
let command: [String]
|
||||
let rawCommand: String?
|
||||
let expected: SystemRunCommandContractExpected
|
||||
}
|
||||
|
||||
private struct SystemRunCommandContractExpected: Decodable {
|
||||
let valid: Bool
|
||||
let displayCommand: String?
|
||||
let errorContains: String?
|
||||
}
|
||||
|
||||
struct ExecSystemRunCommandValidatorTests {
|
||||
@Test func `matches shared system run command contract fixture`() throws {
|
||||
for entry in try Self.loadContractCases() {
|
||||
let result = ExecSystemRunCommandValidator.resolve(command: entry.command, rawCommand: entry.rawCommand)
|
||||
|
||||
if !entry.expected.valid {
|
||||
switch result {
|
||||
case let .ok(resolved):
|
||||
Issue
|
||||
.record("\(entry.name): expected invalid result, got displayCommand=\(resolved.displayCommand)")
|
||||
case let .invalid(message):
|
||||
if let expected = entry.expected.errorContains {
|
||||
#expect(
|
||||
message.contains(expected),
|
||||
"\(entry.name): expected error containing \(expected), got \(message)")
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch result {
|
||||
case let .ok(resolved):
|
||||
#expect(
|
||||
resolved.displayCommand == entry.expected.displayCommand,
|
||||
"\(entry.name): unexpected display command")
|
||||
case let .invalid(message):
|
||||
Issue.record("\(entry.name): unexpected invalid result: \(message)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `validator keeps canonical wrapper text out of allowlist raw parsing`() {
|
||||
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
|
||||
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand)
|
||||
|
||||
switch result {
|
||||
case let .ok(resolved):
|
||||
#expect(resolved.displayCommand == rawCommand)
|
||||
#expect(resolved.evaluationRawCommand == nil)
|
||||
case let .invalid(message):
|
||||
Issue.record("unexpected invalid result: \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `env dash shell wrapper requires canonical raw command binding`() {
|
||||
let command = ["/usr/bin/env", "-", "bash", "-lc", "echo hi"]
|
||||
|
||||
let legacy = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: "echo hi")
|
||||
switch legacy {
|
||||
case .ok:
|
||||
Issue.record("expected rawCommand mismatch for env dash prelude")
|
||||
case let .invalid(message):
|
||||
#expect(message.contains("rawCommand does not match command"))
|
||||
}
|
||||
|
||||
let canonicalRaw = "/usr/bin/env - bash -lc \"echo hi\""
|
||||
let canonical = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: canonicalRaw)
|
||||
switch canonical {
|
||||
case let .ok(resolved):
|
||||
#expect(resolved.displayCommand == canonicalRaw)
|
||||
case let .invalid(message):
|
||||
Issue.record("unexpected invalid result for canonical raw command: \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `fish attached c command requires canonical raw command binding`() {
|
||||
let command = ["/usr/bin/fish", "-c/tmp/payload.fish", "/usr/bin/printf safe_marker"]
|
||||
let result = ExecSystemRunCommandValidator.resolve(
|
||||
command: command,
|
||||
rawCommand: "/usr/bin/printf safe_marker")
|
||||
|
||||
switch result {
|
||||
case .ok:
|
||||
Issue.record("expected rawCommand mismatch for attached fish command payload")
|
||||
case let .invalid(message):
|
||||
#expect(message.contains("rawCommand does not match command"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `startup shell wrappers require canonical raw command binding`() {
|
||||
for command in [
|
||||
["/bin/bash", "-lc", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "--rcfile", "/tmp/payload.sh", "-i", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/bin/bash", "--login", "-c", "/usr/bin/printf safe_marker"],
|
||||
["/usr/bin/fish", "--init-command=/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"],
|
||||
] {
|
||||
let legacy = ExecSystemRunCommandValidator.resolve(
|
||||
command: command,
|
||||
rawCommand: "/usr/bin/printf safe_marker")
|
||||
switch legacy {
|
||||
case .ok:
|
||||
Issue.record("expected rawCommand mismatch for startup shell wrapper")
|
||||
case let .invalid(message):
|
||||
#expect(message.contains("rawCommand does not match command"))
|
||||
}
|
||||
|
||||
let canonicalRaw = ExecCommandFormatter.displayString(for: command)
|
||||
let canonical = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: canonicalRaw)
|
||||
switch canonical {
|
||||
case let .ok(resolved):
|
||||
#expect(resolved.displayCommand == canonicalRaw)
|
||||
case let .invalid(message):
|
||||
Issue.record("unexpected invalid result for canonical raw command: \(message)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
|
||||
let fixtureURL = try self.findContractFixtureURL()
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
let decoded = try JSONDecoder().decode(SystemRunCommandContractFixture.self, from: data)
|
||||
return decoded.cases
|
||||
}
|
||||
|
||||
private static func findContractFixtureURL() throws -> URL {
|
||||
var cursor = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
for _ in 0..<8 {
|
||||
let candidate = cursor
|
||||
.appendingPathComponent("test")
|
||||
.appendingPathComponent("fixtures")
|
||||
.appendingPathComponent("system-run-command-contract.json")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
cursor.deleteLastPathComponent()
|
||||
}
|
||||
throw NSError(
|
||||
domain: "ExecSystemRunCommandValidatorTests",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "missing shared system-run command contract fixture"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct FileHandleLegacyAPIGuardTests {
|
||||
@Test func `sources avoid legacy non throwing file handle read AP is`() throws {
|
||||
let testFile = URL(fileURLWithPath: #filePath)
|
||||
let packageRoot = testFile
|
||||
.deletingLastPathComponent() // OpenClawIPCTests
|
||||
.deletingLastPathComponent() // Tests
|
||||
.deletingLastPathComponent() // apps/macos
|
||||
|
||||
let sourcesRoot = packageRoot.appendingPathComponent("Sources")
|
||||
let swiftFiles = try Self.swiftFiles(under: sourcesRoot)
|
||||
|
||||
var offenders: [String] = []
|
||||
for file in swiftFiles {
|
||||
let raw = try String(contentsOf: file, encoding: .utf8)
|
||||
let stripped = Self.stripCommentsAndStrings(from: raw)
|
||||
|
||||
if stripped.contains("readDataToEndOfFile(") || stripped.contains(".availableData") {
|
||||
offenders.append(file.path)
|
||||
}
|
||||
}
|
||||
|
||||
if !offenders.isEmpty {
|
||||
let message = "Found legacy FileHandle reads in:\n" + offenders.joined(separator: "\n")
|
||||
throw NSError(
|
||||
domain: "FileHandleLegacyAPIGuardTests",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: message])
|
||||
}
|
||||
}
|
||||
|
||||
private static func swiftFiles(under root: URL) throws -> [URL] {
|
||||
let fm = FileManager()
|
||||
guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else {
|
||||
return []
|
||||
}
|
||||
|
||||
var files: [URL] = []
|
||||
for case let url as URL in enumerator {
|
||||
guard url.pathExtension == "swift" else { continue }
|
||||
files.append(url)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
private static func stripCommentsAndStrings(from source: String) -> String {
|
||||
enum Mode {
|
||||
case code
|
||||
case lineComment
|
||||
case blockComment(depth: Int)
|
||||
case string(quoteCount: Int) // 1 = ", 3 = """
|
||||
}
|
||||
|
||||
var mode: Mode = .code
|
||||
var out = ""
|
||||
out.reserveCapacity(source.count)
|
||||
|
||||
var index = source.startIndex
|
||||
func peek(_ offset: Int) -> Character? {
|
||||
guard
|
||||
let i = source.index(index, offsetBy: offset, limitedBy: source.endIndex),
|
||||
i < source.endIndex
|
||||
else { return nil }
|
||||
return source[i]
|
||||
}
|
||||
|
||||
while index < source.endIndex {
|
||||
let ch = source[index]
|
||||
|
||||
switch mode {
|
||||
case .code:
|
||||
if ch == "/", peek(1) == "/" {
|
||||
out.append(" ")
|
||||
index = source.index(index, offsetBy: 2)
|
||||
mode = .lineComment
|
||||
continue
|
||||
}
|
||||
if ch == "/", peek(1) == "*" {
|
||||
out.append(" ")
|
||||
index = source.index(index, offsetBy: 2)
|
||||
mode = .blockComment(depth: 1)
|
||||
continue
|
||||
}
|
||||
if ch == "\"" {
|
||||
let triple = (peek(1) == "\"") && (peek(2) == "\"")
|
||||
out.append(triple ? " " : " ")
|
||||
index = source.index(index, offsetBy: triple ? 3 : 1)
|
||||
mode = .string(quoteCount: triple ? 3 : 1)
|
||||
continue
|
||||
}
|
||||
out.append(ch)
|
||||
index = source.index(after: index)
|
||||
|
||||
case .lineComment:
|
||||
if ch == "\n" {
|
||||
out.append(ch)
|
||||
index = source.index(after: index)
|
||||
mode = .code
|
||||
} else {
|
||||
out.append(" ")
|
||||
index = source.index(after: index)
|
||||
}
|
||||
|
||||
case let .blockComment(depth):
|
||||
if ch == "/", peek(1) == "*" {
|
||||
out.append(" ")
|
||||
index = source.index(index, offsetBy: 2)
|
||||
mode = .blockComment(depth: depth + 1)
|
||||
continue
|
||||
}
|
||||
if ch == "*", peek(1) == "/" {
|
||||
out.append(" ")
|
||||
index = source.index(index, offsetBy: 2)
|
||||
let newDepth = depth - 1
|
||||
mode = newDepth > 0 ? .blockComment(depth: newDepth) : .code
|
||||
continue
|
||||
}
|
||||
out.append(ch == "\n" ? "\n" : " ")
|
||||
index = source.index(after: index)
|
||||
|
||||
case let .string(quoteCount):
|
||||
if ch == "\\", quoteCount == 1 {
|
||||
// Skip escaped character in normal strings.
|
||||
out.append(" ")
|
||||
index = source.index(after: index)
|
||||
if index < source.endIndex {
|
||||
out.append(" ")
|
||||
index = source.index(after: index)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == "\"" {
|
||||
if quoteCount == 3, peek(1) == "\"", peek(2) == "\"" {
|
||||
out.append(" ")
|
||||
index = source.index(index, offsetBy: 3)
|
||||
mode = .code
|
||||
continue
|
||||
}
|
||||
if quoteCount == 1 {
|
||||
out.append(" ")
|
||||
index = source.index(after: index)
|
||||
mode = .code
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.append(ch == "\n" ? "\n" : " ")
|
||||
index = source.index(after: index)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct FileHandleSafeReadTests {
|
||||
@Test func `read to end safely returns empty for closed handle`() {
|
||||
let pipe = Pipe()
|
||||
let handle = pipe.fileHandleForReading
|
||||
try? handle.close()
|
||||
|
||||
let data = handle.readToEndSafely()
|
||||
#expect(data.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `read safely up to count returns empty for closed handle`() {
|
||||
let pipe = Pipe()
|
||||
let handle = pipe.fileHandleForReading
|
||||
try? handle.close()
|
||||
|
||||
let data = handle.readSafely(upToCount: 16)
|
||||
#expect(data.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `read to end safely reads pipe contents`() {
|
||||
let pipe = Pipe()
|
||||
let writeHandle = pipe.fileHandleForWriting
|
||||
writeHandle.write(Data("hello".utf8))
|
||||
try? writeHandle.close()
|
||||
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
#expect(String(data: data, encoding: .utf8) == "hello")
|
||||
}
|
||||
|
||||
@Test func `read safely up to count reads incrementally`() {
|
||||
let pipe = Pipe()
|
||||
let writeHandle = pipe.fileHandleForWriting
|
||||
writeHandle.write(Data("hello world".utf8))
|
||||
try? writeHandle.close()
|
||||
|
||||
let readHandle = pipe.fileHandleForReading
|
||||
let first = readHandle.readSafely(upToCount: 5)
|
||||
let second = readHandle.readSafely(upToCount: 32)
|
||||
|
||||
#expect(String(data: first, encoding: .utf8) == "hello")
|
||||
#expect(String(data: second, encoding: .utf8) == " world")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct GatewayAgentChannelTests {
|
||||
@Test func `should deliver blocks web chat`() {
|
||||
#expect(GatewayAgentChannel.webchat.shouldDeliver(true) == false)
|
||||
#expect(GatewayAgentChannel.webchat.shouldDeliver(false) == false)
|
||||
}
|
||||
|
||||
@Test func `should deliver allows last and provider channels`() {
|
||||
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.googlechat.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.imessage.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
||||
}
|
||||
|
||||
@Test func `init raw normalizes and falls back to last`() {
|
||||
#expect(GatewayAgentChannel(raw: nil) == .last)
|
||||
#expect(GatewayAgentChannel(raw: " ") == .last)
|
||||
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
||||
#expect(GatewayAgentChannel(raw: "googlechat") == .googlechat)
|
||||
#expect(GatewayAgentChannel(raw: "IMESSAGE") == .imessage)
|
||||
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayAutostartPolicyTests {
|
||||
@Test func `starts gateway only when local and not paused`() {
|
||||
#expect(GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: false))
|
||||
#expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: true))
|
||||
#expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .remote, paused: false))
|
||||
#expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .unconfigured, paused: false))
|
||||
}
|
||||
|
||||
@Test func `ensures launch agent when local and not attach only`() {
|
||||
#expect(GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .local,
|
||||
paused: false))
|
||||
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .local,
|
||||
paused: true))
|
||||
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .remote,
|
||||
paused: false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import os
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct GatewayConnectionTests {
|
||||
private func makeConnection(
|
||||
session: GatewayTestWebSocketSession,
|
||||
token: String? = nil) throws -> (GatewayConnection, ConfigSource)
|
||||
{
|
||||
let url = try #require(URL(string: "ws://example.invalid"))
|
||||
let cfg = ConfigSource(token: token)
|
||||
let conn = GatewayConnection(
|
||||
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
return (conn, cfg)
|
||||
}
|
||||
|
||||
private func makeSession(helloDelayMs: Int = 0) -> GatewayTestWebSocketSession {
|
||||
GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(
|
||||
sendHook: { task, message, sendIndex in
|
||||
guard sendIndex > 0 else { return }
|
||||
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
||||
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
|
||||
task.emitReceiveSuccess(.data(response))
|
||||
},
|
||||
receiveHook: { task, receiveIndex in
|
||||
if receiveIndex == 0 {
|
||||
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
||||
}
|
||||
if helloDelayMs > 0 {
|
||||
try await Task.sleep(nanoseconds: UInt64(helloDelayMs) * 1_000_000)
|
||||
}
|
||||
let id = task.snapshotConnectRequestID() ?? "connect"
|
||||
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private final class ConfigSource: @unchecked Sendable {
|
||||
private let token = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||
|
||||
init(token: String?) {
|
||||
self.token.withLock { $0 = token }
|
||||
}
|
||||
|
||||
func snapshotToken() -> String? {
|
||||
self.token.withLock { $0 }
|
||||
}
|
||||
|
||||
func setToken(_ value: String?) {
|
||||
self.token.withLock { $0 = value }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `request reuses single web socket for same config`() async throws {
|
||||
let session = self.makeSession()
|
||||
let (conn, _) = try self.makeConnection(session: session)
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
#expect(session.snapshotCancelCount() == 0)
|
||||
}
|
||||
|
||||
@Test func `request reconfigures and cancels on token change`() async throws {
|
||||
let session = self.makeSession()
|
||||
let (conn, cfg) = try self.makeConnection(session: session, token: "a")
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
|
||||
cfg.setToken("b")
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
#expect(session.snapshotMakeCount() == 2)
|
||||
#expect(session.snapshotCancelCount() == 1)
|
||||
}
|
||||
|
||||
@Test func `concurrent requests still use single web socket`() async throws {
|
||||
let session = self.makeSession(helloDelayMs: 150)
|
||||
let (conn, _) = try self.makeConnection(session: session)
|
||||
|
||||
async let r1: Data = conn.request(method: "status", params: nil)
|
||||
async let r2: Data = conn.request(method: "status", params: nil)
|
||||
_ = try await (r1, r2)
|
||||
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
}
|
||||
|
||||
@Test func `request can disable retries for non idempotent mutations`() async throws {
|
||||
let session = GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(sendHook: { _, _, sendIndex in
|
||||
if sendIndex > 0 {
|
||||
throw URLError(.timedOut)
|
||||
}
|
||||
})
|
||||
})
|
||||
let (conn, _) = try self.makeConnection(session: session)
|
||||
|
||||
do {
|
||||
_ = try await conn.request(
|
||||
method: "sessions.compact",
|
||||
params: nil,
|
||||
timeoutMs: 10,
|
||||
retryTransportFailures: false)
|
||||
Issue.record("expected sessions.compact transport failure")
|
||||
} catch {}
|
||||
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
#expect(session.latestTask()?.snapshotSendCount() == 2)
|
||||
}
|
||||
|
||||
@Test func `subscribe replays latest snapshot`() async throws {
|
||||
let session = self.makeSession()
|
||||
let (conn, _) = try self.makeConnection(session: session)
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
|
||||
let stream = await conn.subscribe(bufferingNewest: 10)
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
let first = await iterator.next()
|
||||
|
||||
guard case let .snapshot(snap) = first else {
|
||||
Issue.record("expected snapshot, got \(String(describing: first))")
|
||||
return
|
||||
}
|
||||
#expect(snap.type == "hello-ok")
|
||||
}
|
||||
|
||||
@Test func `subscribe emits seq gap before event`() async throws {
|
||||
let session = self.makeSession()
|
||||
let (conn, _) = try self.makeConnection(session: session)
|
||||
|
||||
let stream = await conn.subscribe(bufferingNewest: 10)
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
|
||||
_ = try await conn.request(method: "status", params: nil)
|
||||
_ = await iterator.next() // snapshot
|
||||
|
||||
let evt1 = Data(
|
||||
"""
|
||||
{"type":"event","event":"presence","payload":{"presence":[]},"seq":1}
|
||||
""".utf8)
|
||||
session.latestTask()?.emitReceiveSuccess(.data(evt1))
|
||||
|
||||
let firstEvent = await iterator.next()
|
||||
guard case let .event(firstFrame) = firstEvent else {
|
||||
Issue.record("expected event, got \(String(describing: firstEvent))")
|
||||
return
|
||||
}
|
||||
#expect(firstFrame.seq == 1)
|
||||
|
||||
let evt3 = Data(
|
||||
"""
|
||||
{"type":"event","event":"presence","payload":{"presence":[]},"seq":3}
|
||||
""".utf8)
|
||||
session.latestTask()?.emitReceiveSuccess(.data(evt3))
|
||||
|
||||
let gap = await iterator.next()
|
||||
guard case let .seqGap(expected, received) = gap else {
|
||||
Issue.record("expected seqGap, got \(String(describing: gap))")
|
||||
return
|
||||
}
|
||||
#expect(expected == 2)
|
||||
#expect(received == 3)
|
||||
|
||||
let secondEvent = await iterator.next()
|
||||
guard case let .event(secondFrame) = secondEvent else {
|
||||
Issue.record("expected event, got \(String(describing: secondEvent))")
|
||||
return
|
||||
}
|
||||
#expect(secondFrame.seq == 3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayChannelConnectTests {
|
||||
private final class ConnectParamsRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var params: [String: Any]?
|
||||
|
||||
func record(_ message: URLSessionWebSocketTask.Message) {
|
||||
guard let params = GatewayWebSocketTestSupport.connectRequestParams(from: message) else {
|
||||
return
|
||||
}
|
||||
self.lock.lock()
|
||||
self.params = params
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [String: Any]? {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.params
|
||||
}
|
||||
}
|
||||
|
||||
private final class ScopeCapture: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var scopes: [String]?
|
||||
|
||||
func set(_ scopes: [String]?) {
|
||||
self.lock.lock()
|
||||
self.scopes = scopes
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [String]? {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.scopes
|
||||
}
|
||||
}
|
||||
|
||||
private final class TLSFailureSession: WebSocketSessioning, GatewayTLSFailureProviding, @unchecked Sendable {
|
||||
private var failure: GatewayTLSValidationFailure?
|
||||
|
||||
init(failure: GatewayTLSValidationFailure) {
|
||||
self.failure = failure
|
||||
}
|
||||
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
_ = url
|
||||
let task = GatewayTestWebSocketTask(receiveHook: { _, receiveIndex in
|
||||
if receiveIndex == 0 {
|
||||
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
||||
}
|
||||
throw URLError(.userCancelledAuthentication)
|
||||
})
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
|
||||
func consumeLastTLSFailure() -> GatewayTLSValidationFailure? {
|
||||
defer { self.failure = nil }
|
||||
return self.failure
|
||||
}
|
||||
}
|
||||
|
||||
private enum FakeResponse {
|
||||
case helloOk(delayMs: Int)
|
||||
case invalid(delayMs: Int)
|
||||
case authFailed(
|
||||
delayMs: Int,
|
||||
detailCode: String,
|
||||
canRetryWithDeviceToken: Bool,
|
||||
recommendedNextStep: String?)
|
||||
}
|
||||
|
||||
private func makeSession(response: FakeResponse) -> GatewayTestWebSocketSession {
|
||||
GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(
|
||||
receiveHook: { task, receiveIndex in
|
||||
if receiveIndex == 0 {
|
||||
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
||||
}
|
||||
let delayMs: Int
|
||||
let message: URLSessionWebSocketTask.Message
|
||||
switch response {
|
||||
case let .helloOk(ms):
|
||||
delayMs = ms
|
||||
let id = task.snapshotConnectRequestID() ?? "connect"
|
||||
message = .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||
case let .invalid(ms):
|
||||
delayMs = ms
|
||||
message = .string("not json")
|
||||
case let .authFailed(ms, detailCode, canRetryWithDeviceToken, recommendedNextStep):
|
||||
delayMs = ms
|
||||
let id = task.snapshotConnectRequestID() ?? "connect"
|
||||
message = .data(GatewayWebSocketTestSupport.connectAuthFailureData(
|
||||
id: id,
|
||||
detailCode: detailCode,
|
||||
canRetryWithDeviceToken: canRetryWithDeviceToken,
|
||||
recommendedNextStep: recommendedNextStep))
|
||||
}
|
||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
return message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func withTemporaryStateDir<T>(_ operation: () async throws -> T) async throws -> T {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
return try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": tempDir.path]) {
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `concurrent connect is single flight on success`() async throws {
|
||||
let session = self.makeSession(response: .helloOk(delayMs: 200))
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
let t1 = Task { try await channel.connect() }
|
||||
let t2 = Task { try await channel.connect() }
|
||||
|
||||
_ = try await t1.value
|
||||
_ = try await t2.value
|
||||
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
}
|
||||
|
||||
@Test func `connect advertises compatible protocol range`() async throws {
|
||||
let recorder = ConnectParamsRecorder()
|
||||
let session = GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(
|
||||
sendHook: { _, message, sendIndex in
|
||||
guard sendIndex == 0 else { return }
|
||||
recorder.record(message)
|
||||
})
|
||||
})
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
try await channel.connect()
|
||||
|
||||
let params = try #require(recorder.snapshot())
|
||||
#expect(params["minProtocol"] as? Int == GATEWAY_MIN_PROTOCOL_VERSION)
|
||||
#expect(params["maxProtocol"] as? Int == GATEWAY_PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
@Test func `concurrent connect shares failure`() async throws {
|
||||
let session = self.makeSession(response: .invalid(delayMs: 200))
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
let t1 = Task { try await channel.connect() }
|
||||
let t2 = Task { try await channel.connect() }
|
||||
|
||||
let r1 = await t1.result
|
||||
let r2 = await t2.result
|
||||
|
||||
#expect({
|
||||
if case .failure = r1 { true } else { false }
|
||||
}())
|
||||
#expect({
|
||||
if case .failure = r2 { true } else { false }
|
||||
}())
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
}
|
||||
|
||||
@Test func `default operator connect scopes preserve pairing and admin`() async throws {
|
||||
try await self.withTemporaryStateDir {
|
||||
let capture = ScopeCapture()
|
||||
let session = GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(sendHook: { _, message, sendIndex in
|
||||
if sendIndex == 0 {
|
||||
capture.set(GatewayWebSocketTestSupport.connectScopes(from: message))
|
||||
}
|
||||
})
|
||||
})
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
try await channel.connect()
|
||||
|
||||
#expect(capture.snapshot() == [
|
||||
"operator.admin",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `bootstrap token connect scopes are bootstrap-compatible`() async throws {
|
||||
let capture = ScopeCapture()
|
||||
let session = GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(sendHook: { _, message, sendIndex in
|
||||
if sendIndex == 0 {
|
||||
capture.set(GatewayWebSocketTestSupport.connectScopes(from: message))
|
||||
}
|
||||
})
|
||||
})
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
bootstrapToken: "setup-bootstrap-token",
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
try await channel.connect()
|
||||
|
||||
#expect(capture.snapshot() == [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `stored device token connect scopes reuse cached scopes`() async throws {
|
||||
try await self.withTemporaryStateDir {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let storedEntry = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
token: "bootstrap-device-token",
|
||||
scopes: ["operator.read", "operator.write", "operator.approvals"])
|
||||
let capture = ScopeCapture()
|
||||
let session = GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(sendHook: { _, message, sendIndex in
|
||||
if sendIndex == 0 {
|
||||
capture.set(GatewayWebSocketTestSupport.connectScopes(from: message))
|
||||
}
|
||||
})
|
||||
})
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
try await channel.connect()
|
||||
|
||||
#expect(capture.snapshot() == storedEntry.scopes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `explicit device token connect scopes preserve requested scopes`() async throws {
|
||||
try await self.withTemporaryStateDir {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
token: "bootstrap-device-token",
|
||||
scopes: ["operator.read", "operator.write", "operator.approvals"])
|
||||
let requestedScopes = ["operator.admin", "operator.pairing"]
|
||||
let capture = ScopeCapture()
|
||||
let session = GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(sendHook: { _, message, sendIndex in
|
||||
if sendIndex == 0 {
|
||||
capture.set(GatewayWebSocketTestSupport.connectScopes(from: message))
|
||||
}
|
||||
})
|
||||
})
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session),
|
||||
connectOptions: GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: requestedScopes,
|
||||
scopesAreExplicit: true,
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "ui",
|
||||
clientDisplayName: "OpenClaw macOS Debug CLI"))
|
||||
|
||||
try await channel.connect()
|
||||
|
||||
#expect(capture.snapshot() == requestedScopes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `connect surfaces structured auth failure`() async throws {
|
||||
let session = self.makeSession(response: .authFailed(
|
||||
delayMs: 0,
|
||||
detailCode: GatewayConnectAuthDetailCode.authTokenMissing.rawValue,
|
||||
canRetryWithDeviceToken: true,
|
||||
recommendedNextStep: GatewayConnectRecoveryNextStep.updateAuthConfiguration.rawValue))
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
do {
|
||||
try await channel.connect()
|
||||
Issue.record("expected GatewayConnectAuthError")
|
||||
} catch let error as GatewayConnectAuthError {
|
||||
#expect(error.detail == .authTokenMissing)
|
||||
#expect(error.detailCode == GatewayConnectAuthDetailCode.authTokenMissing.rawValue)
|
||||
#expect(error.canRetryWithDeviceToken)
|
||||
#expect(error.recommendedNextStep == .updateAuthConfiguration)
|
||||
#expect(error.recommendedNextStepCode == GatewayConnectRecoveryNextStep.updateAuthConfiguration.rawValue)
|
||||
} catch {
|
||||
Issue.record("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `connect maps user cancelled authentication with cached TLS failure`() async throws {
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "gateway.example.ts.net",
|
||||
storeKey: "gateway.example.ts.net:443",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
let session = TLSFailureSession(failure: failure)
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "wss://gateway.example.ts.net")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
do {
|
||||
try await channel.connect()
|
||||
Issue.record("expected GatewayTLSValidationError")
|
||||
} catch let error as GatewayTLSValidationError {
|
||||
#expect(error.failure == failure)
|
||||
} catch {
|
||||
Issue.record("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
extension NSLock {
|
||||
fileprivate func withDeviceRetryLock<T>(_ body: () -> T) -> T {
|
||||
self.lock()
|
||||
defer { self.unlock() }
|
||||
return body()
|
||||
}
|
||||
}
|
||||
|
||||
private final class ConnectAuthRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var auths: [[String: Any]] = []
|
||||
|
||||
func append(from message: URLSessionWebSocketTask.Message) {
|
||||
guard let auth = Self.connectAuth(from: message) else { return }
|
||||
self.lock.withDeviceRetryLock {
|
||||
self.auths.append(auth)
|
||||
}
|
||||
}
|
||||
|
||||
func auth(at index: Int) -> [String: Any]? {
|
||||
self.lock.withDeviceRetryLock {
|
||||
guard self.auths.indices.contains(index) else { return nil }
|
||||
return self.auths[index]
|
||||
}
|
||||
}
|
||||
|
||||
private static func connectAuth(from message: URLSessionWebSocketTask.Message) -> [String: Any]? {
|
||||
let data: Data? = switch message {
|
||||
case let .data(raw):
|
||||
raw
|
||||
case let .string(text):
|
||||
Data(text.utf8)
|
||||
@unknown default:
|
||||
nil
|
||||
}
|
||||
guard let data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
json["type"] as? String == "req",
|
||||
json["method"] as? String == "connect",
|
||||
let params = json["params"] as? [String: Any],
|
||||
let auth = params["auth"] as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return auth
|
||||
}
|
||||
}
|
||||
|
||||
private final class TrustedDeviceRetryGatewaySession: WebSocketSessioning, GatewayDeviceTokenRetryTrustProviding,
|
||||
@unchecked Sendable {
|
||||
let allowsDeviceTokenRetryAuth: Bool
|
||||
|
||||
private let lock = NSLock()
|
||||
private let recorder: ConnectAuthRecorder
|
||||
private var makeCount = 0
|
||||
|
||||
init(recorder: ConnectAuthRecorder, allowsDeviceTokenRetryAuth: Bool) {
|
||||
self.recorder = recorder
|
||||
self.allowsDeviceTokenRetryAuth = allowsDeviceTokenRetryAuth
|
||||
}
|
||||
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
_ = url
|
||||
let attemptIndex = self.lock.withDeviceRetryLock { () -> Int in
|
||||
let current = self.makeCount
|
||||
self.makeCount += 1
|
||||
return current
|
||||
}
|
||||
let recorder = self.recorder
|
||||
let task = GatewayTestWebSocketTask(
|
||||
sendHook: { _, message, sendIndex in
|
||||
if sendIndex == 0 {
|
||||
recorder.append(from: message)
|
||||
}
|
||||
},
|
||||
receiveHook: { task, receiveIndex in
|
||||
if receiveIndex == 0 {
|
||||
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
||||
}
|
||||
let id = task.snapshotConnectRequestID() ?? "connect"
|
||||
if attemptIndex == 0 {
|
||||
return .data(GatewayWebSocketTestSupport.connectAuthFailureData(
|
||||
id: id,
|
||||
detailCode: GatewayConnectAuthDetailCode.authTokenMismatch.rawValue,
|
||||
canRetryWithDeviceToken: true,
|
||||
recommendedNextStep: GatewayConnectRecoveryNextStep.retryWithDeviceToken.rawValue))
|
||||
}
|
||||
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||
})
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayChannelDeviceTokenRetryTests {
|
||||
@Test func `remote pinned TLS retries stale shared token with stored device token`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": tempDir.path]) {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
token: "stored-device-token")
|
||||
|
||||
let recorder = ConnectAuthRecorder()
|
||||
let session = TrustedDeviceRetryGatewaySession(
|
||||
recorder: recorder,
|
||||
allowsDeviceTokenRetryAuth: true)
|
||||
let options = GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "ui",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: true)
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "wss://gateway.example.com")),
|
||||
token: "stale-shared-token",
|
||||
session: WebSocketSessionBox(session: session),
|
||||
connectOptions: options)
|
||||
|
||||
do {
|
||||
try await channel.connect()
|
||||
Issue.record("expected stale shared-token connect to fail before device-token retry")
|
||||
} catch let error as GatewayConnectAuthError {
|
||||
#expect(error.detail == .authTokenMismatch)
|
||||
}
|
||||
|
||||
try await channel.connect()
|
||||
|
||||
let firstAuth = try #require(recorder.auth(at: 0))
|
||||
#expect(firstAuth["token"] as? String == "stale-shared-token")
|
||||
#expect(firstAuth["deviceToken"] == nil)
|
||||
|
||||
let retryAuth = try #require(recorder.auth(at: 1))
|
||||
#expect(retryAuth["token"] as? String == "stale-shared-token")
|
||||
#expect(retryAuth["deviceToken"] as? String == "stored-device-token")
|
||||
|
||||
await channel.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct GatewayChannelRequestTests {
|
||||
private func makeSession(requestSendDelayMs: Int) -> GatewayTestWebSocketSession {
|
||||
GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(
|
||||
sendHook: { _, _, sendIndex in
|
||||
guard sendIndex == 1 else { return }
|
||||
try await Task.sleep(nanoseconds: UInt64(requestSendDelayMs) * 1_000_000)
|
||||
throw URLError(.cannotConnectToHost)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@Test func `request timeout then send failure does not double resume`() async throws {
|
||||
let session = self.makeSession(requestSendDelayMs: 100)
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
do {
|
||||
_ = try await channel.request(method: "test", params: nil, timeoutMs: 10)
|
||||
Issue.record("Expected request to time out")
|
||||
} catch {
|
||||
let ns = error as NSError
|
||||
#expect(ns.domain == "Gateway")
|
||||
#expect(ns.code == 5)
|
||||
}
|
||||
|
||||
// Give the delayed send failure task time to run; this used to crash due to a double-resume.
|
||||
try? await Task.sleep(nanoseconds: 250 * 1_000_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct GatewayChannelShutdownTests {
|
||||
@Test func `shutdown prevents reconnect loop from receive failure`() async throws {
|
||||
let session = GatewayTestWebSocketSession()
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
// Establish a connection so `listen()` is active.
|
||||
try await channel.connect()
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
|
||||
// Simulate a socket receive failure, which would normally schedule a reconnect.
|
||||
session.latestTask()?.emitReceiveFailure()
|
||||
|
||||
// Shut down quickly, before backoff reconnect triggers.
|
||||
await channel.shutdown()
|
||||
|
||||
// Wait longer than the default reconnect backoff (500ms) to ensure no reconnect happens.
|
||||
try? await Task.sleep(nanoseconds: 750 * 1_000_000)
|
||||
|
||||
#expect(session.snapshotMakeCount() == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
@testable import OpenClawIPC
|
||||
|
||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
var state: URLSessionTask.State = .running
|
||||
var autoRespond = false
|
||||
private(set) var sentMessages: [URLSessionWebSocketTask.Message] = []
|
||||
private var sentChallenge = false
|
||||
private var respondedRequestIds = Set<String>()
|
||||
|
||||
func resume() {}
|
||||
|
||||
func cancel(with _: URLSessionWebSocketTask.CloseCode, reason _: Data?) {
|
||||
self.state = .canceling
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
self.sentMessages.append(message)
|
||||
}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
if self.autoRespond {
|
||||
if !self.sentChallenge {
|
||||
self.sentChallenge = true
|
||||
return .string("""
|
||||
{"type":"event","event":"connect.challenge","payload":{"nonce":"test-nonce"}}
|
||||
""")
|
||||
}
|
||||
if let request = self.latestUnrespondedRequest() {
|
||||
self.respondedRequestIds.insert(request.id)
|
||||
if request.method == "connect" {
|
||||
return .string("""
|
||||
{"type":"res","id":"\(request.id)","ok":true,"payload":{"type":"hello","protocol":3,"server":{},"features":{},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"auth":{},"policy":{}}}
|
||||
""")
|
||||
}
|
||||
return .string("""
|
||||
{"type":"res","id":"\(request.id)","ok":true,"payload":{}}
|
||||
""")
|
||||
}
|
||||
}
|
||||
throw URLError(.cannotConnectToHost)
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
completionHandler(.failure(URLError(.cannotConnectToHost)))
|
||||
}
|
||||
|
||||
private func latestUnrespondedRequest() -> (id: String, method: String)? {
|
||||
for message in self.sentMessages.reversed() {
|
||||
let data: Data? = switch message {
|
||||
case let .string(text):
|
||||
Data(text.utf8)
|
||||
case let .data(raw):
|
||||
raw
|
||||
@unknown default:
|
||||
nil
|
||||
}
|
||||
guard let data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let id = json["id"] as? String,
|
||||
let method = json["method"] as? String,
|
||||
!self.respondedRequestIds.contains(id)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
return (id, method)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
let task = FakeWebSocketTask()
|
||||
|
||||
func makeWebSocketTask(url _: URL) -> WebSocketTaskBox {
|
||||
WebSocketTaskBox(task: self.task)
|
||||
}
|
||||
}
|
||||
|
||||
private final class WebSocketMessageRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var messages: [URLSessionWebSocketTask.Message] = []
|
||||
|
||||
func append(_ message: URLSessionWebSocketTask.Message) {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
self.messages.append(message)
|
||||
}
|
||||
|
||||
func snapshot() -> [URLSessionWebSocketTask.Message] {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.messages
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSession) {
|
||||
let session = FakeWebSocketSession()
|
||||
let connection = GatewayConnection(
|
||||
configProvider: {
|
||||
(url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil)
|
||||
},
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
return (connection, session)
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionControlTests {
|
||||
@Test func `status fails when process missing`() async {
|
||||
let (connection, _) = makeTestGatewayConnection()
|
||||
let result = await connection.status()
|
||||
await connection.shutdown()
|
||||
#expect(result.ok == false)
|
||||
#expect(result.error != nil)
|
||||
}
|
||||
|
||||
@Test func `reject empty message`() async {
|
||||
let (connection, _) = makeTestGatewayConnection()
|
||||
let result = await connection.sendAgent(
|
||||
message: "",
|
||||
thinking: nil,
|
||||
sessionKey: "main",
|
||||
deliver: false,
|
||||
to: nil)
|
||||
#expect(result.ok == false)
|
||||
}
|
||||
|
||||
@Test func `send agent keeps empty voice wake trigger field`() async throws {
|
||||
let recorder = WebSocketMessageRecorder()
|
||||
let session = GatewayTestWebSocketSession(taskFactory: {
|
||||
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
|
||||
recorder.append(message)
|
||||
guard sendIndex > 0,
|
||||
let id = GatewayWebSocketTestSupport.requestID(from: message)
|
||||
else { return }
|
||||
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
||||
})
|
||||
})
|
||||
let connection = GatewayConnection(
|
||||
configProvider: {
|
||||
(url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil)
|
||||
},
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
let result = await connection.sendAgent(GatewayAgentInvocation(
|
||||
message: "test",
|
||||
sessionKey: "main",
|
||||
thinking: nil,
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: .last,
|
||||
timeoutSeconds: nil,
|
||||
idempotencyKey: "idem-1",
|
||||
voiceWakeTrigger: " "))
|
||||
await connection.shutdown()
|
||||
#expect(result.ok == true)
|
||||
|
||||
guard let agentMessage = recorder.snapshot().reversed().first(where: { message in
|
||||
guard let data = Self.messageData(message),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return false }
|
||||
return json["method"] as? String == "agent"
|
||||
}) else {
|
||||
Issue.record("expected agent websocket send payload")
|
||||
return
|
||||
}
|
||||
|
||||
guard let payloadData = Self.messageData(agentMessage) else {
|
||||
Issue.record("unexpected agent websocket message type")
|
||||
return
|
||||
}
|
||||
|
||||
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
|
||||
let params = json?["params"] as? [String: Any]
|
||||
#expect(params?["thinking"] == nil)
|
||||
#expect(params?["voiceWakeTrigger"] as? String == "")
|
||||
}
|
||||
|
||||
@Test func `chat send omits thinking when inheriting session default`() async throws {
|
||||
let recorder = WebSocketMessageRecorder()
|
||||
let session = GatewayTestWebSocketSession(taskFactory: {
|
||||
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
|
||||
recorder.append(message)
|
||||
guard sendIndex > 0,
|
||||
let data = Self.messageData(message),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let id = json["id"] as? String
|
||||
else { return }
|
||||
task.emitReceiveSuccess(.data(Self.chatSendOkResponseData(id: id)))
|
||||
})
|
||||
})
|
||||
let connection = GatewayConnection(
|
||||
configProvider: {
|
||||
(url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil)
|
||||
},
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
_ = try await connection.chatSend(
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
thinking: nil,
|
||||
idempotencyKey: "chat-1",
|
||||
attachments: [])
|
||||
await connection.shutdown()
|
||||
|
||||
guard let chatMessage = recorder.snapshot().reversed().first(where: { message in
|
||||
guard let data = Self.messageData(message),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return false }
|
||||
return json["method"] as? String == "chat.send"
|
||||
}) else {
|
||||
Issue.record("expected chat.send websocket payload")
|
||||
return
|
||||
}
|
||||
|
||||
guard let payloadData = Self.messageData(chatMessage) else {
|
||||
Issue.record("unexpected chat.send websocket message type")
|
||||
return
|
||||
}
|
||||
|
||||
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
|
||||
let params = json?["params"] as? [String: Any]
|
||||
#expect(params?["thinking"] == nil)
|
||||
}
|
||||
|
||||
private static func messageData(_ message: URLSessionWebSocketTask.Message) -> Data? {
|
||||
switch message {
|
||||
case let .string(text):
|
||||
Data(text.utf8)
|
||||
case let .data(data):
|
||||
data
|
||||
@unknown default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func chatSendOkResponseData(id: String) -> Data {
|
||||
Data("""
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": { "runId": "chat-1", "status": "ok" }
|
||||
}
|
||||
""".utf8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
import OpenClawDiscovery
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct GatewayDiscoveryHelpersTests {
|
||||
private func makeGateway(
|
||||
serviceHost: String?,
|
||||
servicePort: Int?,
|
||||
lanHost: String? = "txt-host.local",
|
||||
tailnetDns: String? = "txt-host.ts.net",
|
||||
sshPort: Int = 22,
|
||||
gatewayPort: Int? = 18789,
|
||||
gatewayTls: Bool = false) -> GatewayDiscoveryModel.DiscoveredGateway
|
||||
{
|
||||
GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: serviceHost,
|
||||
servicePort: servicePort,
|
||||
lanHost: lanHost,
|
||||
tailnetDns: tailnetDns,
|
||||
sshPort: sshPort,
|
||||
gatewayPort: gatewayPort,
|
||||
gatewayTls: gatewayTls,
|
||||
cliPath: "/tmp/openclaw",
|
||||
stableID: UUID().uuidString,
|
||||
debugID: UUID().uuidString,
|
||||
isLocal: false)
|
||||
}
|
||||
|
||||
private func assertSSHTarget(
|
||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
host: String,
|
||||
port: Int)
|
||||
{
|
||||
guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else {
|
||||
Issue.record("expected ssh target")
|
||||
return
|
||||
}
|
||||
let parsed = CommandResolver.parseSSHTarget(target)
|
||||
#expect(parsed?.host == host)
|
||||
#expect(parsed?.port == port)
|
||||
}
|
||||
|
||||
@Test func `ssh target uses resolved service host only`() {
|
||||
let gateway = self.makeGateway(
|
||||
serviceHost: "resolved.example.ts.net",
|
||||
servicePort: 18789,
|
||||
sshPort: 2201)
|
||||
self.assertSSHTarget(for: gateway, host: "resolved.example.ts.net", port: 2201)
|
||||
}
|
||||
|
||||
@Test func `ssh target allows missing resolved service port`() {
|
||||
let gateway = self.makeGateway(
|
||||
serviceHost: "resolved.example.ts.net",
|
||||
servicePort: nil,
|
||||
sshPort: 2201)
|
||||
self.assertSSHTarget(for: gateway, host: "resolved.example.ts.net", port: 2201)
|
||||
}
|
||||
|
||||
@Test func `ssh target rejects txt only gateways`() {
|
||||
let gateway = self.makeGateway(
|
||||
serviceHost: nil,
|
||||
servicePort: nil,
|
||||
lanHost: "txt-only.local",
|
||||
tailnetDns: "txt-only.ts.net",
|
||||
sshPort: 2222)
|
||||
|
||||
#expect(GatewayDiscoveryHelpers.sshTarget(for: gateway) == nil)
|
||||
}
|
||||
|
||||
@Test func `direct url uses resolved service endpoint only`() {
|
||||
let tlsGateway = self.makeGateway(
|
||||
serviceHost: "resolved.example.ts.net",
|
||||
servicePort: 443,
|
||||
gatewayTls: true)
|
||||
#expect(GatewayDiscoveryHelpers.directUrl(for: tlsGateway) == "wss://resolved.example.ts.net")
|
||||
|
||||
let wsGateway = self.makeGateway(
|
||||
serviceHost: "resolved.example.ts.net",
|
||||
servicePort: 18789)
|
||||
#expect(GatewayDiscoveryHelpers.directUrl(for: wsGateway) == "ws://resolved.example.ts.net:18789")
|
||||
|
||||
let localGateway = self.makeGateway(
|
||||
serviceHost: "127.0.0.1",
|
||||
servicePort: 18789)
|
||||
#expect(GatewayDiscoveryHelpers.directUrl(for: localGateway) == "ws://127.0.0.1:18789")
|
||||
}
|
||||
|
||||
@Test func `direct url rejects public plaintext service endpoint`() {
|
||||
let gateway = self.makeGateway(
|
||||
serviceHost: "gateway.example",
|
||||
servicePort: 18789,
|
||||
gatewayTls: false)
|
||||
|
||||
#expect(GatewayDiscoveryHelpers.directUrl(for: gateway) == nil)
|
||||
}
|
||||
|
||||
@Test func `direct url rejects txt only fallback`() {
|
||||
let gateway = self.makeGateway(
|
||||
serviceHost: nil,
|
||||
servicePort: nil,
|
||||
lanHost: "txt-only.local",
|
||||
tailnetDns: "txt-only.ts.net",
|
||||
gatewayPort: 22222)
|
||||
|
||||
#expect(GatewayDiscoveryHelpers.directUrl(for: gateway) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import Testing
|
||||
@testable import OpenClawDiscovery
|
||||
|
||||
@MainActor
|
||||
struct GatewayDiscoveryModelTests {
|
||||
@Test func `local gateway matches lan host`() {
|
||||
let local = GatewayDiscoveryModel.LocalIdentity(
|
||||
hostTokens: ["studio"],
|
||||
displayTokens: [])
|
||||
#expect(GatewayDiscoveryModel.isLocalGateway(
|
||||
lanHost: "studio.local",
|
||||
tailnetDns: nil,
|
||||
displayName: nil,
|
||||
serviceName: nil,
|
||||
local: local))
|
||||
}
|
||||
|
||||
@Test func `local gateway matches tailnet dns`() {
|
||||
let local = GatewayDiscoveryModel.LocalIdentity(
|
||||
hostTokens: ["studio"],
|
||||
displayTokens: [])
|
||||
#expect(GatewayDiscoveryModel.isLocalGateway(
|
||||
lanHost: nil,
|
||||
tailnetDns: "studio.tailnet.example",
|
||||
displayName: nil,
|
||||
serviceName: nil,
|
||||
local: local))
|
||||
}
|
||||
|
||||
@Test func `local gateway matches display name`() {
|
||||
let local = GatewayDiscoveryModel.LocalIdentity(
|
||||
hostTokens: [],
|
||||
displayTokens: ["peter's mac studio"])
|
||||
#expect(GatewayDiscoveryModel.isLocalGateway(
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
displayName: "Peter's Mac Studio (OpenClaw)",
|
||||
serviceName: nil,
|
||||
local: local))
|
||||
}
|
||||
|
||||
@Test func `remote gateway does not match`() {
|
||||
let local = GatewayDiscoveryModel.LocalIdentity(
|
||||
hostTokens: ["studio"],
|
||||
displayTokens: ["peter's mac studio"])
|
||||
#expect(!GatewayDiscoveryModel.isLocalGateway(
|
||||
lanHost: "other.local",
|
||||
tailnetDns: "other.tailnet.example",
|
||||
displayName: "Other Mac",
|
||||
serviceName: "other-gateway",
|
||||
local: local))
|
||||
}
|
||||
|
||||
@Test func `local gateway matches service name`() {
|
||||
let local = GatewayDiscoveryModel.LocalIdentity(
|
||||
hostTokens: ["studio"],
|
||||
displayTokens: [])
|
||||
#expect(GatewayDiscoveryModel.isLocalGateway(
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
displayName: nil,
|
||||
serviceName: "studio-gateway",
|
||||
local: local))
|
||||
}
|
||||
|
||||
@Test func `service name does not false positive on substring host token`() {
|
||||
let local = GatewayDiscoveryModel.LocalIdentity(
|
||||
hostTokens: ["steipete"],
|
||||
displayTokens: [])
|
||||
#expect(!GatewayDiscoveryModel.isLocalGateway(
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
displayName: nil,
|
||||
serviceName: "steipetacstudio (OpenClaw)",
|
||||
local: local))
|
||||
#expect(GatewayDiscoveryModel.isLocalGateway(
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
displayName: nil,
|
||||
serviceName: "steipete (OpenClaw)",
|
||||
local: local))
|
||||
}
|
||||
|
||||
@Test func `parses gateway TXT fields`() {
|
||||
let parsed = GatewayDiscoveryModel.parseGatewayTXT([
|
||||
"lanHost": " studio.local ",
|
||||
"tailnetDns": " peters-mac-studio-1.ts.net ",
|
||||
"sshPort": " 2222 ",
|
||||
"gatewayPort": " 18799 ",
|
||||
"gatewayTls": " yes ",
|
||||
"gatewayDirectReachable": " true ",
|
||||
"cliPath": " /opt/openclaw ",
|
||||
])
|
||||
#expect(parsed.lanHost == "studio.local")
|
||||
#expect(parsed.tailnetDns == "peters-mac-studio-1.ts.net")
|
||||
#expect(parsed.sshPort == 2222)
|
||||
#expect(parsed.gatewayPort == 18799)
|
||||
#expect(parsed.gatewayTls)
|
||||
#expect(parsed.gatewayDirectReachable)
|
||||
#expect(parsed.cliPath == "/opt/openclaw")
|
||||
}
|
||||
|
||||
@Test func `parses gateway TXT defaults`() {
|
||||
let parsed = GatewayDiscoveryModel.parseGatewayTXT([
|
||||
"lanHost": " ",
|
||||
"tailnetDns": "\n",
|
||||
"gatewayPort": "nope",
|
||||
"sshPort": "nope",
|
||||
])
|
||||
#expect(parsed.lanHost == nil)
|
||||
#expect(parsed.tailnetDns == nil)
|
||||
#expect(parsed.sshPort == 22)
|
||||
#expect(parsed.gatewayPort == nil)
|
||||
#expect(!parsed.gatewayTls)
|
||||
#expect(!parsed.gatewayDirectReachable)
|
||||
#expect(parsed.cliPath == nil)
|
||||
}
|
||||
|
||||
@Test func `builds SSH target`() {
|
||||
#expect(GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: "peter",
|
||||
host: "studio.local",
|
||||
port: 22) == "peter@studio.local")
|
||||
#expect(GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: "peter",
|
||||
host: "studio.local",
|
||||
port: 2201) == "peter@studio.local:2201")
|
||||
}
|
||||
|
||||
@Test func `tailscale serve discovery continues when DNS-SD already found a remote gateway`() {
|
||||
let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Nearby Gateway",
|
||||
serviceHost: "nearby-gateway.local",
|
||||
servicePort: 18789,
|
||||
lanHost: "nearby-gateway.local",
|
||||
tailnetDns: nil,
|
||||
sshPort: 22,
|
||||
gatewayPort: 18789,
|
||||
cliPath: nil,
|
||||
stableID: "bonjour|nearby-gateway",
|
||||
debugID: "bonjour",
|
||||
isLocal: false)
|
||||
|
||||
#expect(GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery(
|
||||
currentGateways: [dnsSdGateway],
|
||||
tailscaleServeGateways: []))
|
||||
}
|
||||
|
||||
@Test func `tailscale serve discovery stops after serve result is found`() {
|
||||
let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Nearby Gateway",
|
||||
serviceHost: "nearby-gateway.local",
|
||||
servicePort: 18789,
|
||||
lanHost: "nearby-gateway.local",
|
||||
tailnetDns: nil,
|
||||
sshPort: 22,
|
||||
gatewayPort: 18789,
|
||||
cliPath: nil,
|
||||
stableID: "bonjour|nearby-gateway",
|
||||
debugID: "bonjour",
|
||||
isLocal: false)
|
||||
let serveGateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Tailscale Gateway",
|
||||
serviceHost: "gateway-host.tailnet-example.ts.net",
|
||||
servicePort: 443,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 443,
|
||||
cliPath: nil,
|
||||
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
|
||||
debugID: "serve",
|
||||
isLocal: false)
|
||||
|
||||
#expect(!GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery(
|
||||
currentGateways: [dnsSdGateway],
|
||||
tailscaleServeGateways: [serveGateway]))
|
||||
}
|
||||
|
||||
@Test func `dedupe key prefers resolved endpoint across sources`() {
|
||||
let wideArea = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: "gateway-host.tailnet-example.ts.net",
|
||||
servicePort: 443,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 443,
|
||||
cliPath: nil,
|
||||
stableID: "wide-area|openclaw.internal.|gateway-host",
|
||||
debugID: "wide-area",
|
||||
isLocal: false)
|
||||
let serve = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: "gateway-host.tailnet-example.ts.net",
|
||||
servicePort: 443,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 443,
|
||||
cliPath: nil,
|
||||
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
|
||||
debugID: "serve",
|
||||
isLocal: false)
|
||||
|
||||
#expect(GatewayDiscoveryModel.dedupeKey(for: wideArea) == GatewayDiscoveryModel.dedupeKey(for: serve))
|
||||
}
|
||||
|
||||
@Test func `dedupe key falls back to stable ID without endpoint`() {
|
||||
let unresolved = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: nil,
|
||||
servicePort: nil,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
|
||||
debugID: "serve",
|
||||
isLocal: false)
|
||||
|
||||
#expect(GatewayDiscoveryModel
|
||||
.dedupeKey(for: unresolved) == "stable|tailscale-serve|gateway-host.tailnet-example.ts.net")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import Foundation
|
||||
import OpenClawDiscovery
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct GatewayDiscoverySelectionSupportTests {
|
||||
private func makeGateway(
|
||||
serviceHost: String?,
|
||||
servicePort: Int?,
|
||||
tailnetDns: String? = nil,
|
||||
sshPort: Int = 22,
|
||||
gatewayTls: Bool = false,
|
||||
gatewayDirectReachable: Bool = false,
|
||||
stableID: String) -> GatewayDiscoveryModel.DiscoveredGateway
|
||||
{
|
||||
GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: serviceHost,
|
||||
servicePort: servicePort,
|
||||
lanHost: nil,
|
||||
tailnetDns: tailnetDns,
|
||||
sshPort: sshPort,
|
||||
gatewayPort: servicePort,
|
||||
gatewayTls: gatewayTls,
|
||||
gatewayDirectReachable: gatewayDirectReachable,
|
||||
cliPath: nil,
|
||||
stableID: stableID,
|
||||
debugID: UUID().uuidString,
|
||||
isLocal: false)
|
||||
}
|
||||
|
||||
@Test func `selecting tailscale serve gateway switches to direct transport`() async {
|
||||
let tailnetHost = "gateway-host.tailnet-example.ts.net"
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
|
||||
let state = AppState(preview: true)
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteTarget = "user@old-host"
|
||||
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(
|
||||
gateway: self.makeGateway(
|
||||
serviceHost: tailnetHost,
|
||||
servicePort: 443,
|
||||
tailnetDns: tailnetHost,
|
||||
gatewayTls: true,
|
||||
stableID: "tailscale-serve|\(tailnetHost)"),
|
||||
state: state)
|
||||
|
||||
#expect(state.remoteTransport == .direct)
|
||||
#expect(state.remoteUrl == "wss://\(tailnetHost)")
|
||||
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == tailnetHost)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `selecting merged tailnet gateway still switches to direct transport`() async {
|
||||
let tailnetHost = "gateway-host.tailnet-example.ts.net"
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
|
||||
let state = AppState(preview: true)
|
||||
state.remoteTransport = .ssh
|
||||
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(
|
||||
gateway: self.makeGateway(
|
||||
serviceHost: tailnetHost,
|
||||
servicePort: 443,
|
||||
tailnetDns: tailnetHost,
|
||||
gatewayTls: true,
|
||||
stableID: "wide-area|openclaw.internal.|gateway-host"),
|
||||
state: state)
|
||||
|
||||
#expect(state.remoteTransport == .direct)
|
||||
#expect(state.remoteUrl == "wss://\(tailnetHost)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `legacy tailnet discovery without reachability flags still switches to direct transport`() async {
|
||||
let tailnetHost = "gateway-host.tailnet-example.ts.net"
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
|
||||
let state = AppState(preview: true)
|
||||
state.remoteTransport = .ssh
|
||||
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(
|
||||
gateway: self.makeGateway(
|
||||
serviceHost: tailnetHost,
|
||||
servicePort: 18789,
|
||||
tailnetDns: tailnetHost,
|
||||
stableID: "wide-area|openclaw.internal.|gateway-host"),
|
||||
state: state)
|
||||
|
||||
#expect(state.remoteTransport == .direct)
|
||||
#expect(state.remoteUrl == "ws://\(tailnetHost):18789")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `selecting nearby lan gateway keeps ssh without direct reachability signal`() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
|
||||
let state = AppState(preview: true)
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteTarget = "user@old-host"
|
||||
state.remoteUrl = "ws://localhost:29876"
|
||||
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(
|
||||
gateway: self.makeGateway(
|
||||
serviceHost: "nearby-gateway.local",
|
||||
servicePort: 18789,
|
||||
stableID: "bonjour|nearby-gateway"),
|
||||
state: state)
|
||||
|
||||
#expect(state.remoteTransport == .ssh)
|
||||
#expect(state.remoteUrl == "ws://127.0.0.1:29876")
|
||||
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == "nearby-gateway.local")
|
||||
|
||||
let configRoot = OpenClawConfigFile.loadDict()
|
||||
let remote = ((configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
|
||||
#expect(remote["transport"] as? String == "ssh")
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `selecting direct reachable lan gateway ignores stale local tunnel port`() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
|
||||
let state = AppState(preview: true)
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteUrl = "ws://localhost:29876"
|
||||
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(
|
||||
gateway: self.makeGateway(
|
||||
serviceHost: "nearby-gateway.local",
|
||||
servicePort: 19999,
|
||||
gatewayDirectReachable: true,
|
||||
stableID: "bonjour|nearby-gateway-custom"),
|
||||
state: state)
|
||||
|
||||
#expect(state.remoteTransport == .direct)
|
||||
#expect(state.remoteUrl == "ws://nearby-gateway.local:19999")
|
||||
|
||||
let configRoot = OpenClawConfigFile.loadDict()
|
||||
let remote = ((configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
|
||||
#expect(remote["transport"] as? String == "direct")
|
||||
#expect(remote["url"] as? String == "ws://nearby-gateway.local:19999")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct GatewayEndpointStoreTests {
|
||||
private func makeLaunchAgentSnapshot(
|
||||
env: [String: String],
|
||||
token: String?,
|
||||
password: String?) -> LaunchAgentPlistSnapshot
|
||||
{
|
||||
LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
environment: env,
|
||||
stdoutPath: nil,
|
||||
stderrPath: nil,
|
||||
port: nil,
|
||||
bind: nil,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
|
||||
@Test func `resolve gateway token prefers env and falls back to launchd`() {
|
||||
let snapshot = self.makeLaunchAgentSnapshot(
|
||||
env: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"],
|
||||
token: "launchd-token",
|
||||
password: nil)
|
||||
|
||||
let envToken = GatewayEndpointStore._testResolveGatewayToken(
|
||||
isRemote: false,
|
||||
root: [:],
|
||||
env: ["OPENCLAW_GATEWAY_TOKEN": "env-token"],
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(envToken == "env-token")
|
||||
|
||||
let fallbackToken = GatewayEndpointStore._testResolveGatewayToken(
|
||||
isRemote: false,
|
||||
root: [:],
|
||||
env: [:],
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(fallbackToken == "launchd-token")
|
||||
}
|
||||
|
||||
@Test func `resolve gateway token ignores launchd in remote mode`() {
|
||||
let snapshot = self.makeLaunchAgentSnapshot(
|
||||
env: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"],
|
||||
token: "launchd-token",
|
||||
password: nil)
|
||||
|
||||
let token = GatewayEndpointStore._testResolveGatewayToken(
|
||||
isRemote: true,
|
||||
root: [:],
|
||||
env: [:],
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(token == nil)
|
||||
}
|
||||
|
||||
@Test func `resolve gateway token uses remote config token`() {
|
||||
let token = GatewayEndpointStore._testResolveGatewayToken(
|
||||
isRemote: true,
|
||||
root: [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"token": " remote-token ",
|
||||
],
|
||||
],
|
||||
],
|
||||
env: [:],
|
||||
launchdSnapshot: nil)
|
||||
#expect(token == "remote-token")
|
||||
}
|
||||
|
||||
@Test func `remote password resolver trims remote config password`() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"password": " remote-pass ",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
#expect(GatewayRemoteConfig.resolvePasswordString(root: root) == "remote-pass")
|
||||
}
|
||||
|
||||
@Test func `resolve gateway password falls back to launchd`() {
|
||||
let snapshot = self.makeLaunchAgentSnapshot(
|
||||
env: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"],
|
||||
token: nil,
|
||||
password: "launchd-pass")
|
||||
|
||||
let password = GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: [:],
|
||||
env: [:],
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(password == "launchd-pass")
|
||||
}
|
||||
|
||||
@Test func `connection mode resolver prefers config mode over defaults`() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("remote", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": " local ",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .local)
|
||||
}
|
||||
|
||||
@Test func `connection mode resolver trims config mode`() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": " remote ",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func `connection mode resolver falls back to defaults when missing config`() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("remote", forKey: connectionModeKey)
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func `connection mode resolver falls back to defaults on unknown config`() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": "staging",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .local)
|
||||
}
|
||||
|
||||
@Test func `connection mode resolver prefers remote URL when mode missing`() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": " ws://umbrel:18789 ",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func `resolve local gateway host uses loopback for auto even with tailnet`() {
|
||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "auto",
|
||||
tailscaleIP: "100.64.1.2")
|
||||
#expect(host == "127.0.0.1")
|
||||
}
|
||||
|
||||
@Test func `resolve local gateway host uses loopback for auto without tailnet`() {
|
||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "auto",
|
||||
tailscaleIP: nil)
|
||||
#expect(host == "127.0.0.1")
|
||||
}
|
||||
|
||||
@Test func `resolve local gateway host prefers tailnet for tailnet mode`() {
|
||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "tailnet",
|
||||
tailscaleIP: "100.64.1.5")
|
||||
#expect(host == "100.64.1.5")
|
||||
}
|
||||
|
||||
@Test func `resolve local gateway host falls back to loopback for tailnet mode`() {
|
||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "tailnet",
|
||||
tailscaleIP: nil)
|
||||
#expect(host == "127.0.0.1")
|
||||
}
|
||||
|
||||
@Test func `resolve local gateway host uses custom bind host`() {
|
||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "custom",
|
||||
tailscaleIP: "100.64.1.9",
|
||||
customBindHost: "192.168.1.10")
|
||||
#expect(host == "192.168.1.10")
|
||||
}
|
||||
|
||||
@Test func `local config uses local gateway auth and host resolution`() {
|
||||
let snapshot = self.makeLaunchAgentSnapshot(
|
||||
env: [:],
|
||||
token: "launchd-token",
|
||||
password: "launchd-pass")
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"bind": "tailnet",
|
||||
"tls": ["enabled": true],
|
||||
"remote": [
|
||||
"url": "wss://remote.example:443",
|
||||
"token": "remote-token",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let config = GatewayEndpointStore._testLocalConfig(
|
||||
root: root,
|
||||
env: [:],
|
||||
launchdSnapshot: snapshot,
|
||||
tailscaleIP: "100.64.1.8")
|
||||
|
||||
#expect(config.url.absoluteString == "wss://100.64.1.8:\(GatewayEnvironment.gatewayPort())")
|
||||
#expect(config.token == "launchd-token")
|
||||
#expect(config.password == "launchd-pass")
|
||||
}
|
||||
|
||||
@Test func `dashboard URL uses local base path in local mode`() throws {
|
||||
let config: GatewayConnection.Config = try (
|
||||
url: #require(URL(string: "ws://127.0.0.1:18789")),
|
||||
token: nil,
|
||||
password: nil)
|
||||
|
||||
let url = try GatewayEndpointStore.dashboardURL(
|
||||
for: config,
|
||||
mode: .local,
|
||||
localBasePath: " control ")
|
||||
#expect(url.absoluteString == "http://127.0.0.1:18789/control/")
|
||||
}
|
||||
|
||||
@Test func `dashboard URL skips local base path in remote mode`() throws {
|
||||
let config: GatewayConnection.Config = try (
|
||||
url: #require(URL(string: "ws://gateway.example:18789")),
|
||||
token: nil,
|
||||
password: nil)
|
||||
|
||||
let url = try GatewayEndpointStore.dashboardURL(
|
||||
for: config,
|
||||
mode: .remote,
|
||||
localBasePath: "/local-ui")
|
||||
#expect(url.absoluteString == "http://gateway.example:18789/")
|
||||
}
|
||||
|
||||
@Test func `dashboard URL prefers path from config URL`() throws {
|
||||
let config: GatewayConnection.Config = try (
|
||||
url: #require(URL(string: "wss://gateway.example:443/remote-ui")),
|
||||
token: nil,
|
||||
password: nil)
|
||||
|
||||
let url = try GatewayEndpointStore.dashboardURL(
|
||||
for: config,
|
||||
mode: .remote,
|
||||
localBasePath: "/local-ui")
|
||||
#expect(url.absoluteString == "https://gateway.example:443/remote-ui/")
|
||||
}
|
||||
|
||||
@Test func `dashboard URL uses fragment token and omits password`() throws {
|
||||
let config: GatewayConnection.Config = try (
|
||||
url: #require(URL(string: "ws://127.0.0.1:18789")),
|
||||
token: "abc123",
|
||||
password: "sekret") // pragma: allowlist secret
|
||||
|
||||
let url = try GatewayEndpointStore.dashboardURL(
|
||||
for: config,
|
||||
mode: .local,
|
||||
localBasePath: "/control")
|
||||
#expect(url.absoluteString == "http://127.0.0.1:18789/control/#token=abc123")
|
||||
#expect(url.query == nil)
|
||||
}
|
||||
|
||||
@Test func `dashboard URL can use native auth token override`() throws {
|
||||
let config: GatewayConnection.Config = try (
|
||||
url: #require(URL(string: "ws://127.0.0.1:18789")),
|
||||
token: nil,
|
||||
password: "sekret") // pragma: allowlist secret
|
||||
|
||||
let url = try GatewayEndpointStore.dashboardURL(
|
||||
for: config,
|
||||
mode: .local,
|
||||
localBasePath: "/control",
|
||||
authToken: "device-token")
|
||||
#expect(url.absoluteString == "http://127.0.0.1:18789/control/#token=device-token")
|
||||
#expect(url.query == nil)
|
||||
}
|
||||
|
||||
@Test func `normalize gateway url adds default port for loopback ws`() {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1")
|
||||
#expect(url?.port == 18789)
|
||||
#expect(url?.absoluteString == "ws://127.0.0.1:18789")
|
||||
}
|
||||
|
||||
@Test func `normalize gateway url accepts private network ws`() {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://192.168.0.202:18789")
|
||||
#expect(url?.absoluteString == "ws://192.168.0.202:18789")
|
||||
}
|
||||
|
||||
@Test func `normalize gateway url accepts tailnet ws`() {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://100.123.224.76:18789")
|
||||
#expect(url?.absoluteString == "ws://100.123.224.76:18789")
|
||||
}
|
||||
|
||||
@Test func `missing transport infers direct from private remote URL`() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://192.168.0.202:18789",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
|
||||
#expect(resolution.transport == .direct)
|
||||
#expect(resolution.source == .inferredRemoteURL)
|
||||
#expect(resolution.directURL?.absoluteString == "ws://192.168.0.202:18789")
|
||||
}
|
||||
|
||||
@Test func `legacy loopback URL keeps SSH even with trusted SSH target`() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://127.0.0.1:18789",
|
||||
"sshTarget": "steipete@192.168.0.202",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
|
||||
#expect(resolution.transport == .ssh)
|
||||
#expect(resolution.source == .legacySSH)
|
||||
#expect(resolution.directURL == nil)
|
||||
}
|
||||
|
||||
@Test func `explicit ssh keeps legacy tunnel even when target is direct capable`() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"transport": "ssh",
|
||||
"url": "ws://127.0.0.1:18789",
|
||||
"sshTarget": "steipete@192.168.0.202",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
|
||||
#expect(resolution.transport == .ssh)
|
||||
#expect(resolution.source == .explicit)
|
||||
#expect(resolution.directURL == nil)
|
||||
}
|
||||
|
||||
@Test func `normalize gateway url rejects public host ws`() {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789")
|
||||
#expect(url == nil)
|
||||
}
|
||||
|
||||
@Test func `normalize gateway url rejects private ipv4 suffix host bypasses`() {
|
||||
#expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://192.168.0.202.attacker.example:18789") == nil)
|
||||
#expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://100.123.224.76.attacker.example:18789") == nil)
|
||||
}
|
||||
|
||||
@Test func `normalize gateway url rejects ipv6 prefix hostname bypasses`() {
|
||||
#expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://fcorp.example:18789") == nil)
|
||||
#expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://fd-example.com:18789") == nil)
|
||||
}
|
||||
|
||||
@Test func `normalize gateway url rejects prefix bypass loopback host`() {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.attacker.example")
|
||||
#expect(url == nil)
|
||||
}
|
||||
|
||||
@Test func `resolve tls fingerprint trims remote config value`() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": " sha256:ABC123 ",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: root) == "sha256:ABC123")
|
||||
}
|
||||
|
||||
@Test func `resolve tls fingerprint ignores blank or non string values`() {
|
||||
let blank: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": " ",
|
||||
],
|
||||
],
|
||||
]
|
||||
let nonString: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": 123,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: blank) == nil)
|
||||
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: nonString) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct GatewayEnvironmentTests {
|
||||
@Test func `semver parses common forms`() {
|
||||
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse(" v1.2.3 \n") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
|
||||
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped
|
||||
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
|
||||
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
|
||||
#expect(Semver.parse("v1.2.3+build.9") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
|
||||
#expect(Semver.parse(nil) == nil)
|
||||
#expect(Semver.parse("invalid") == nil)
|
||||
#expect(Semver.parse("1.2") == nil)
|
||||
#expect(Semver.parse("1.2.x") == nil)
|
||||
// Product-prefixed output from `openclaw --version` should NOT parse as semver
|
||||
// (the prefix must be stripped by the caller, not the parser).
|
||||
#expect(Semver.parse("OpenClaw 2026.3.23-1") == nil)
|
||||
}
|
||||
|
||||
@Test func `gateway version output strips product prefix before parsing`() {
|
||||
let normalized = GatewayEnvironment.normalizeGatewayVersionOutput(" OpenClaw 2026.3.23-1 \n")
|
||||
#expect(normalized == "2026.3.23-1")
|
||||
#expect(Semver.parse(normalized) == Semver(major: 2026, minor: 3, patch: 23))
|
||||
}
|
||||
|
||||
@Test func `gateway version output strips trailing commit hash`() {
|
||||
let normalized = GatewayEnvironment.normalizeGatewayVersionOutput("OpenClaw 2026.4.2 (d74a122)")
|
||||
#expect(normalized == "2026.4.2")
|
||||
#expect(Semver.parse(normalized) == Semver(major: 2026, minor: 4, patch: 2))
|
||||
|
||||
// Pre-release suffix + commit hash combined
|
||||
let normalized2 = GatewayEnvironment.normalizeGatewayVersionOutput("OpenClaw 2026.4.2-1 (d74a122)")
|
||||
#expect(normalized2 == "2026.4.2-1")
|
||||
#expect(Semver.parse(normalized2) == Semver(major: 2026, minor: 4, patch: 2))
|
||||
}
|
||||
|
||||
@Test func `semver compatibility requires same major and not older`() {
|
||||
let required = Semver(major: 2, minor: 1, patch: 0)
|
||||
#expect(Semver(major: 2, minor: 1, patch: 0).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 2, patch: 0).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 1, patch: 1).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 0, patch: 9).compatible(with: required) == false)
|
||||
#expect(Semver(major: 3, minor: 0, patch: 0).compatible(with: required) == false)
|
||||
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
||||
}
|
||||
|
||||
@Test func `gateway port defaults and respects override`() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withIsolatedState(
|
||||
env: ["OPENCLAW_CONFIG_PATH": configPath],
|
||||
defaults: ["gatewayPort": nil])
|
||||
{
|
||||
let defaultPort = GatewayEnvironment.gatewayPort()
|
||||
#expect(defaultPort == 18789)
|
||||
|
||||
UserDefaults.standard.set(19999, forKey: "gatewayPort")
|
||||
defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
|
||||
#expect(GatewayEnvironment.gatewayPort() == 19999)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `expected gateway version from string uses parser`() {
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(
|
||||
major: 2026,
|
||||
minor: 1,
|
||||
patch: 11))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
|
||||
struct GatewayFrameDecodeTests {
|
||||
@Test func `decodes event frame with any codable payload`() throws {
|
||||
let json = """
|
||||
{
|
||||
"type": "event",
|
||||
"event": "presence",
|
||||
"payload": { "foo": "bar", "count": 1 },
|
||||
"seq": 7
|
||||
}
|
||||
"""
|
||||
|
||||
let frame = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8))
|
||||
|
||||
#expect({
|
||||
if case .event = frame { true } else { false }
|
||||
}(), "expected .event frame")
|
||||
|
||||
guard case let .event(evt) = frame else {
|
||||
return
|
||||
}
|
||||
|
||||
let payload = evt.payload?.value as? [String: AnyCodable]
|
||||
#expect(payload?["foo"]?.value as? String == "bar")
|
||||
#expect(payload?["count"]?.value as? Int == 1)
|
||||
#expect(evt.seq == 7)
|
||||
}
|
||||
|
||||
@Test func `decodes request frame with nested params`() throws {
|
||||
let json = """
|
||||
{
|
||||
"type": "req",
|
||||
"id": "1",
|
||||
"method": "agent.send",
|
||||
"params": {
|
||||
"text": "hi",
|
||||
"items": [1, null, {"ok": true}],
|
||||
"meta": { "count": 2 }
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let frame = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8))
|
||||
|
||||
#expect({
|
||||
if case .req = frame { true } else { false }
|
||||
}(), "expected .req frame")
|
||||
|
||||
guard case let .req(req) = frame else {
|
||||
return
|
||||
}
|
||||
|
||||
let params = req.params?.value as? [String: AnyCodable]
|
||||
#expect(params?["text"]?.value as? String == "hi")
|
||||
|
||||
let items = params?["items"]?.value as? [AnyCodable]
|
||||
#expect(items?.count == 3)
|
||||
#expect(items?[0].value as? Int == 1)
|
||||
#expect(items?[1].value is NSNull)
|
||||
|
||||
let item2 = items?[2].value as? [String: AnyCodable]
|
||||
#expect(item2?["ok"]?.value as? Bool == true)
|
||||
|
||||
let meta = params?["meta"]?.value as? [String: AnyCodable]
|
||||
#expect(meta?["count"]?.value as? Int == 2)
|
||||
}
|
||||
|
||||
@Test func `decodes unknown frame and preserves raw`() throws {
|
||||
let json = """
|
||||
{
|
||||
"type": "made-up",
|
||||
"foo": "bar",
|
||||
"count": 1,
|
||||
"nested": { "ok": true }
|
||||
}
|
||||
"""
|
||||
|
||||
let frame = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8))
|
||||
|
||||
#expect({
|
||||
if case .unknown = frame { true } else { false }
|
||||
}(), "expected .unknown frame")
|
||||
|
||||
guard case let .unknown(type, raw) = frame else {
|
||||
return
|
||||
}
|
||||
|
||||
#expect(type == "made-up")
|
||||
#expect(raw["type"]?.value as? String == "made-up")
|
||||
#expect(raw["foo"]?.value as? String == "bar")
|
||||
#expect(raw["count"]?.value as? Int == 1)
|
||||
let nested = raw["nested"]?.value as? [String: AnyCodable]
|
||||
#expect(nested?["ok"]?.value as? Bool == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct GatewayLaunchAgentManagerTests {
|
||||
@Test func `attach only runtime override does not uninstall gateway launch agent`() throws {
|
||||
let dir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-attach-only-\(UUID().uuidString)", isDirectory: true)
|
||||
let marker = dir.appendingPathComponent("disable-launchagent")
|
||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager().removeItem(at: dir) }
|
||||
defer {
|
||||
GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(nil)
|
||||
GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(false)
|
||||
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
||||
}
|
||||
|
||||
GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(marker)
|
||||
GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(true)
|
||||
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
||||
|
||||
let error = GatewayLaunchAgentManager.applyAttachOnlyRuntimeOverride()
|
||||
|
||||
#expect(error == nil)
|
||||
#expect(FileManager().fileExists(atPath: marker.path))
|
||||
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty)
|
||||
}
|
||||
|
||||
@Test func `launch agent plist snapshot parses args and env`() throws {
|
||||
let url = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist")
|
||||
let plist: [String: Any] = [
|
||||
"ProgramArguments": ["openclaw", "gateway", "--port", "18789", "--bind", "loopback"],
|
||||
"EnvironmentVariables": [
|
||||
"OPENCLAW_GATEWAY_TOKEN": " secret ",
|
||||
"OPENCLAW_GATEWAY_PASSWORD": "pw",
|
||||
],
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
defer { try? FileManager().removeItem(at: url) }
|
||||
|
||||
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||
#expect(snapshot.port == 18789)
|
||||
#expect(snapshot.bind == "loopback")
|
||||
#expect(snapshot.token == "secret")
|
||||
#expect(snapshot.password == "pw")
|
||||
}
|
||||
|
||||
@Test func `launch agent plist snapshot allows missing bind`() throws {
|
||||
let url = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist")
|
||||
let plist: [String: Any] = [
|
||||
"ProgramArguments": ["openclaw", "gateway", "--port", "18789"],
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
defer { try? FileManager().removeItem(at: url) }
|
||||
|
||||
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||
#expect(snapshot.port == 18789)
|
||||
#expect(snapshot.bind == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct GatewayProcessManagerTests {
|
||||
@Test func `clears last failure when health succeeds`() async throws {
|
||||
let session = GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(
|
||||
sendHook: { task, message, sendIndex in
|
||||
guard sendIndex > 0 else { return }
|
||||
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
||||
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
||||
})
|
||||
})
|
||||
let url = try #require(URL(string: "ws://example.invalid"))
|
||||
let connection = GatewayConnection(
|
||||
configProvider: { (url: url, token: nil, password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
let manager = GatewayProcessManager.shared
|
||||
manager.setTestingConnection(connection)
|
||||
manager.setTestingDesiredActive(true)
|
||||
manager.setTestingLastFailureReason("health failed")
|
||||
defer {
|
||||
manager.setTestingConnection(nil)
|
||||
manager.setTestingDesiredActive(false)
|
||||
manager.setTestingLastFailureReason(nil)
|
||||
}
|
||||
|
||||
let ready = await manager.waitForGatewayReady(timeout: 0.5)
|
||||
#expect(ready)
|
||||
#expect(manager.lastFailureReason == nil)
|
||||
}
|
||||
|
||||
@Test func `attaches to existing gateway without spawning launchd`() async throws {
|
||||
let port = 19097
|
||||
try await TestIsolation.withEnvValues(["OPENCLAW_GATEWAY_PORT": "\(port)"]) {
|
||||
let healthData = Data(
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"ts": 1,
|
||||
"durationMs": 0,
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"configured": true,
|
||||
"linked": true,
|
||||
"authAgeMs": 60000
|
||||
}
|
||||
},
|
||||
"channelOrder": ["telegram"],
|
||||
"channelLabels": {
|
||||
"telegram": "Telegram"
|
||||
},
|
||||
"heartbeatSeconds": 30,
|
||||
"sessions": {
|
||||
"path": "/tmp/sessions",
|
||||
"count": 1,
|
||||
"recent": []
|
||||
}
|
||||
}
|
||||
""".utf8)
|
||||
let session = GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(
|
||||
sendHook: { task, message, sendIndex in
|
||||
guard sendIndex > 0 else { return }
|
||||
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": \(String(decoding: healthData, as: UTF8.self))
|
||||
}
|
||||
"""
|
||||
task.emitReceiveSuccess(.data(Data(json.utf8)))
|
||||
})
|
||||
})
|
||||
let url = try #require(URL(string: "ws://example.invalid"))
|
||||
let connection = GatewayConnection(
|
||||
configProvider: { (url: url, token: nil, password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
let descriptor = PortGuardian.Descriptor(
|
||||
pid: 4242,
|
||||
command: "openclaw-gateway",
|
||||
executablePath: "/tmp/openclaw-gateway")
|
||||
|
||||
let manager = GatewayProcessManager.shared
|
||||
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
||||
manager.setTestingConnection(connection)
|
||||
manager.setTestingSkipControlChannelRefresh(true)
|
||||
manager.setTestingLastFailureReason("stale")
|
||||
|
||||
@MainActor
|
||||
func cleanup() async {
|
||||
manager.setTestingConnection(nil)
|
||||
manager.setTestingSkipControlChannelRefresh(false)
|
||||
manager.setTestingDesiredActive(false)
|
||||
manager.setTestingLastFailureReason(nil)
|
||||
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
||||
}
|
||||
|
||||
do {
|
||||
let attached = await manager._testAttachExistingGatewayIfAvailable()
|
||||
#expect(attached)
|
||||
#expect(manager.lastFailureReason == nil)
|
||||
guard case let .attachedExisting(statusDetails) = manager.status else {
|
||||
Issue.record("expected attachedExisting status")
|
||||
await cleanup()
|
||||
return
|
||||
}
|
||||
let details = try #require(statusDetails)
|
||||
#expect(details.contains("port \(port)"))
|
||||
#expect(details.contains("Telegram linked"))
|
||||
#expect(details.contains("auth 1m"))
|
||||
#expect(details.contains("pid 4242 openclaw-gateway @ /tmp/openclaw-gateway"))
|
||||
await cleanup()
|
||||
} catch {
|
||||
await cleanup()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
extension WebSocketTasking {
|
||||
/// Keep unit-test doubles resilient to protocol additions.
|
||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
||||
pongReceiveHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
enum GatewayWebSocketTestSupport {
|
||||
static func connectChallengeData(nonce: String = "test-nonce") -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "event",
|
||||
"event": "connect.challenge",
|
||||
"payload": { "nonce": "\(nonce)" }
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
static func connectRequestID(from message: URLSessionWebSocketTask.Message) -> String? {
|
||||
guard let obj = self.requestFrameObject(from: message) else { return nil }
|
||||
guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else {
|
||||
return nil
|
||||
}
|
||||
return obj["id"] as? String
|
||||
}
|
||||
|
||||
static func connectRequestParams(from message: URLSessionWebSocketTask.Message) -> [String: Any]? {
|
||||
guard let obj = self.requestFrameObject(from: message) else { return nil }
|
||||
guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else {
|
||||
return nil
|
||||
}
|
||||
return obj["params"] as? [String: Any]
|
||||
}
|
||||
|
||||
static func connectScopes(from message: URLSessionWebSocketTask.Message) -> [String]? {
|
||||
guard let obj = self.requestFrameObject(from: message) else { return nil }
|
||||
guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else {
|
||||
return nil
|
||||
}
|
||||
let params = obj["params"] as? [String: Any]
|
||||
return params?["scopes"] as? [String]
|
||||
}
|
||||
|
||||
static func connectOkData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"protocol": 2,
|
||||
"server": { "version": "test", "connId": "test" },
|
||||
"features": { "methods": [], "events": [] },
|
||||
"snapshot": {
|
||||
"presence": [ { "ts": 1 } ],
|
||||
"health": {},
|
||||
"stateVersion": { "presence": 0, "health": 0 },
|
||||
"uptimeMs": 0
|
||||
},
|
||||
"auth": { "role": "operator", "scopes": [] },
|
||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
static func connectAuthFailureData(
|
||||
id: String,
|
||||
detailCode: String,
|
||||
message: String = "gateway auth rejected",
|
||||
canRetryWithDeviceToken: Bool = false,
|
||||
recommendedNextStep: String? = nil) -> Data
|
||||
{
|
||||
let recommendedNextStepJson = if let recommendedNextStep {
|
||||
"""
|
||||
,
|
||||
"recommendedNextStep": "\(recommendedNextStep)"
|
||||
"""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "INVALID_REQUEST",
|
||||
"message": "\(message)",
|
||||
"details": {
|
||||
"code": "\(detailCode)",
|
||||
"canRetryWithDeviceToken": \(canRetryWithDeviceToken ? "true" : "false")
|
||||
\(recommendedNextStepJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
static func requestID(from message: URLSessionWebSocketTask.Message) -> String? {
|
||||
guard let obj = self.requestFrameObject(from: message) else { return nil }
|
||||
guard (obj["type"] as? String) == "req" else {
|
||||
return nil
|
||||
}
|
||||
return obj["id"] as? String
|
||||
}
|
||||
|
||||
private static func requestFrameObject(from message: URLSessionWebSocketTask.Message) -> [String: Any]? {
|
||||
let data: Data? = switch message {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else { return nil }
|
||||
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
}
|
||||
|
||||
static func okResponseData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": { "ok": true }
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSLock {
|
||||
@inline(__always)
|
||||
fileprivate func withLock<T>(_ body: () throws -> T) rethrows -> T {
|
||||
self.lock(); defer { self.unlock() }
|
||||
return try body()
|
||||
}
|
||||
}
|
||||
|
||||
final class GatewayTestWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
typealias SendHook = @Sendable (GatewayTestWebSocketTask, URLSessionWebSocketTask.Message, Int) async throws -> Void
|
||||
typealias ReceiveHook = @Sendable (GatewayTestWebSocketTask, Int) async throws -> URLSessionWebSocketTask.Message
|
||||
|
||||
private let lock = NSLock()
|
||||
private let sendHook: SendHook?
|
||||
private let receiveHook: ReceiveHook?
|
||||
private var _state: URLSessionTask.State = .suspended
|
||||
private var connectRequestID: String?
|
||||
private var sendCount = 0
|
||||
private var receiveCount = 0
|
||||
private var cancelCount = 0
|
||||
private var pendingReceiveHandler: (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
|
||||
init(sendHook: SendHook? = nil, receiveHook: ReceiveHook? = nil) {
|
||||
self.sendHook = sendHook
|
||||
self.receiveHook = receiveHook
|
||||
}
|
||||
|
||||
var state: URLSessionTask.State {
|
||||
get { self.lock.withLock { self._state } }
|
||||
set { self.lock.withLock { self._state = newValue } }
|
||||
}
|
||||
|
||||
func snapshotCancelCount() -> Int {
|
||||
self.lock.withLock { self.cancelCount }
|
||||
}
|
||||
|
||||
func snapshotConnectRequestID() -> String? {
|
||||
self.lock.withLock { self.connectRequestID }
|
||||
}
|
||||
|
||||
func snapshotSendCount() -> Int {
|
||||
self.lock.withLock { self.sendCount }
|
||||
}
|
||||
|
||||
func resume() {
|
||||
self.state = .running
|
||||
}
|
||||
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
_ = (closeCode, reason)
|
||||
let handler = self.lock.withLock { () -> (@Sendable (Result<
|
||||
URLSessionWebSocketTask.Message,
|
||||
Error,
|
||||
>) -> Void)? in
|
||||
self._state = .canceling
|
||||
self.cancelCount += 1
|
||||
defer { self.pendingReceiveHandler = nil }
|
||||
return self.pendingReceiveHandler
|
||||
}
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
let sendIndex = self.lock.withLock { () -> Int in
|
||||
let current = self.sendCount
|
||||
self.sendCount += 1
|
||||
return current
|
||||
}
|
||||
if sendIndex == 0, let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
|
||||
self.lock.withLock { self.connectRequestID = id }
|
||||
}
|
||||
try await self.sendHook?(self, message, sendIndex)
|
||||
}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
let receiveIndex = self.lock.withLock { () -> Int in
|
||||
let current = self.receiveCount
|
||||
self.receiveCount += 1
|
||||
return current
|
||||
}
|
||||
if let receiveHook = self.receiveHook {
|
||||
return try await receiveHook(self, receiveIndex)
|
||||
}
|
||||
if receiveIndex == 0 {
|
||||
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
||||
}
|
||||
let id = self.snapshotConnectRequestID() ?? "connect"
|
||||
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||
}
|
||||
|
||||
func receive(
|
||||
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
{
|
||||
self.lock.withLock { self.pendingReceiveHandler = completionHandler }
|
||||
}
|
||||
|
||||
func emitReceiveSuccess(_ message: URLSessionWebSocketTask.Message) {
|
||||
let handler = self.lock.withLock { self.pendingReceiveHandler }
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(message))
|
||||
}
|
||||
|
||||
func emitReceiveFailure(_ error: Error = URLError(.networkConnectionLost)) {
|
||||
let handler = self.lock.withLock { self.pendingReceiveHandler }
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
final class GatewayTestWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
typealias TaskFactory = @Sendable () -> GatewayTestWebSocketTask
|
||||
|
||||
private let lock = NSLock()
|
||||
private let taskFactory: TaskFactory
|
||||
private var tasks: [GatewayTestWebSocketTask] = []
|
||||
private var makeCount = 0
|
||||
|
||||
init(taskFactory: @escaping TaskFactory = { GatewayTestWebSocketTask() }) {
|
||||
self.taskFactory = taskFactory
|
||||
}
|
||||
|
||||
func snapshotMakeCount() -> Int {
|
||||
self.lock.withLock { self.makeCount }
|
||||
}
|
||||
|
||||
func snapshotCancelCount() -> Int {
|
||||
self.lock.withLock { self.tasks.reduce(0) { $0 + $1.snapshotCancelCount() } }
|
||||
}
|
||||
|
||||
func latestTask() -> GatewayTestWebSocketTask? {
|
||||
self.lock.withLock { self.tasks.last }
|
||||
}
|
||||
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
_ = url
|
||||
let task = self.taskFactory()
|
||||
self.lock.withLock {
|
||||
self.makeCount += 1
|
||||
self.tasks.append(task)
|
||||
}
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
32
apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift
Normal file
32
apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct HealthDecodeTests {
|
||||
private let sampleJSON: String = // minimal but complete payload
|
||||
"""
|
||||
{"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
|
||||
"""
|
||||
|
||||
@Test func `decodes clean JSON`() {
|
||||
let data = Data(sampleJSON.utf8)
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
|
||||
#expect(snap?.channels["whatsapp"]?.linked == true)
|
||||
#expect(snap?.sessions.count == 1)
|
||||
}
|
||||
|
||||
@Test func `decodes with leading noise`() {
|
||||
let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer"
|
||||
let snap = decodeHealthSnapshot(from: Data(noisy.utf8))
|
||||
|
||||
#expect(snap?.channels["telegram"]?.probe?.elapsedMs == 800)
|
||||
}
|
||||
|
||||
@Test func `fails without braces`() {
|
||||
let data = Data("no json here".utf8)
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
|
||||
#expect(snap == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct HealthStoreStateTests {
|
||||
@Test @MainActor func `linked channel probe failure degrades state`() {
|
||||
let snap = HealthSnapshot(
|
||||
ok: true,
|
||||
ts: 0,
|
||||
durationMs: 1,
|
||||
channels: [
|
||||
"whatsapp": .init(
|
||||
configured: true,
|
||||
linked: true,
|
||||
authAgeMs: 1,
|
||||
probe: .init(
|
||||
ok: false,
|
||||
status: 503,
|
||||
error: "gateway connect failed",
|
||||
elapsedMs: 12,
|
||||
bot: nil,
|
||||
webhook: nil),
|
||||
lastProbeAt: 0),
|
||||
],
|
||||
channelOrder: ["whatsapp"],
|
||||
channelLabels: ["whatsapp": "WhatsApp"],
|
||||
heartbeatSeconds: 60,
|
||||
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []))
|
||||
|
||||
let store = HealthStore.shared
|
||||
store.__setSnapshotForTest(snap, lastError: nil)
|
||||
|
||||
switch store.state {
|
||||
case let .degraded(message):
|
||||
#expect(!message.isEmpty)
|
||||
default:
|
||||
Issue.record("Expected degraded state when probe fails for linked channel")
|
||||
}
|
||||
|
||||
#expect(store.summaryLine.contains("probe degraded"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct HostEnvSanitizerTests {
|
||||
@Test func `sanitize blocks shell trace variables`() {
|
||||
let env = HostEnvSanitizer.sanitize(overrides: [
|
||||
"SHELLOPTS": "xtrace",
|
||||
"PS4": "$(touch /tmp/pwned)",
|
||||
"OPENCLAW_TEST": "1",
|
||||
])
|
||||
#expect(env["SHELLOPTS"] == nil)
|
||||
#expect(env["PS4"] == nil)
|
||||
#expect(env["OPENCLAW_TEST"] == "1")
|
||||
}
|
||||
|
||||
@Test func `sanitize shell wrapper allows only explicit override keys`() {
|
||||
let env = HostEnvSanitizer.sanitize(
|
||||
overrides: [
|
||||
"LANG": "C",
|
||||
"LC_ALL": "C",
|
||||
"OPENCLAW_TOKEN": "secret",
|
||||
"PS4": "$(touch /tmp/pwned)",
|
||||
],
|
||||
shellWrapper: true)
|
||||
|
||||
#expect(env["LANG"] == "C")
|
||||
#expect(env["LC_ALL"] == "C")
|
||||
#expect(env["OPENCLAW_TOKEN"] == nil)
|
||||
#expect(env["PS4"] == nil)
|
||||
}
|
||||
|
||||
@Test func `sanitize non shell wrapper keeps regular overrides`() {
|
||||
let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"])
|
||||
#expect(env["OPENCLAW_TOKEN"] == "secret")
|
||||
}
|
||||
|
||||
@Test func `inspect overrides rejects blocked and invalid keys`() {
|
||||
let diagnostics = HostEnvSanitizer.inspectOverrides(overrides: [
|
||||
"CLASSPATH": "/tmp/evil-classpath",
|
||||
"BAD-KEY": "x",
|
||||
"ProgramFiles(x86)": "C:\\Program Files (x86)",
|
||||
])
|
||||
|
||||
#expect(diagnostics.blockedKeys == ["CLASSPATH"])
|
||||
#expect(diagnostics.invalidKeys == ["BAD-KEY"])
|
||||
}
|
||||
|
||||
@Test func `sanitize accepts Windows-style override key names`() {
|
||||
let env = HostEnvSanitizer.sanitize(overrides: [
|
||||
"ProgramFiles(x86)": "D:\\SDKs",
|
||||
"CommonProgramFiles(x86)": "D:\\Common",
|
||||
])
|
||||
#expect(env["ProgramFiles(x86)"] == "D:\\SDKs")
|
||||
#expect(env["CommonProgramFiles(x86)"] == "D:\\Common")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct HoverHUDControllerTests {
|
||||
@Test func `hover HUD controller presents and dismisses`() async {
|
||||
let controller = HoverHUDController()
|
||||
controller.setSuppressed(false)
|
||||
|
||||
controller.statusItemHoverChanged(
|
||||
inside: true,
|
||||
anchorProvider: { NSRect(x: 10, y: 10, width: 24, height: 24) })
|
||||
try? await Task.sleep(nanoseconds: 260_000_000)
|
||||
|
||||
controller.panelHoverChanged(inside: true)
|
||||
controller.panelHoverChanged(inside: false)
|
||||
controller.statusItemHoverChanged(
|
||||
inside: false,
|
||||
anchorProvider: { NSRect(x: 10, y: 10, width: 24, height: 24) })
|
||||
|
||||
controller.dismiss(reason: "test")
|
||||
controller.setSuppressed(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct InstancesSettingsSmokeTests {
|
||||
@Test func `instances settings builds body with multiple instances`() {
|
||||
let store = InstancesStore(isPreview: true)
|
||||
store.statusMessage = "Loaded"
|
||||
store.instances = [
|
||||
InstanceInfo(
|
||||
id: "macbook",
|
||||
host: "macbook-pro",
|
||||
ip: "10.0.0.2",
|
||||
version: "1.2.3",
|
||||
platform: "macOS 15.1",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "MacBookPro18,1",
|
||||
lastInputSeconds: 15,
|
||||
mode: "local",
|
||||
reason: "heartbeat",
|
||||
text: "MacBook Pro local",
|
||||
ts: 1_700_000_000_000),
|
||||
InstanceInfo(
|
||||
id: "android",
|
||||
host: "pixel",
|
||||
ip: "10.0.0.3",
|
||||
version: "2.0.0",
|
||||
platform: "Android 14",
|
||||
deviceFamily: "Android",
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: 120,
|
||||
mode: "node",
|
||||
reason: "presence",
|
||||
text: "Android node",
|
||||
ts: 1_700_000_100_000),
|
||||
InstanceInfo(
|
||||
id: "gateway",
|
||||
host: "gateway",
|
||||
ip: "10.0.0.4",
|
||||
version: "3.0.0",
|
||||
platform: "iOS 18",
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: nil,
|
||||
mode: "gateway",
|
||||
reason: "gateway",
|
||||
text: "Gateway",
|
||||
ts: 1_700_000_200_000),
|
||||
]
|
||||
|
||||
let view = InstancesSettings(store: store)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `instances settings exercises helpers`() {
|
||||
InstancesSettings.exerciseForTesting()
|
||||
}
|
||||
}
|
||||
36
apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift
Normal file
36
apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct InstancesStoreTests {
|
||||
@Test
|
||||
@MainActor
|
||||
func `presence event payload decodes via JSON encoder`() {
|
||||
// Build a payload that mirrors the gateway's presence event shape:
|
||||
// { "presence": [ PresenceEntry ] }
|
||||
let entry: [String: OpenClawProtocol.AnyCodable] = [
|
||||
"host": .init("gw"),
|
||||
"ip": .init("10.0.0.1"),
|
||||
"version": .init("2.0.0"),
|
||||
"mode": .init("gateway"),
|
||||
"lastInputSeconds": .init(5),
|
||||
"reason": .init("test"),
|
||||
"text": .init("Gateway node"),
|
||||
"ts": .init(1_730_000_000),
|
||||
]
|
||||
let payloadMap: [String: OpenClawProtocol.AnyCodable] = [
|
||||
"presence": .init([OpenClawProtocol.AnyCodable(entry)]),
|
||||
]
|
||||
let payload = OpenClawProtocol.AnyCodable(payloadMap)
|
||||
|
||||
let store = InstancesStore(isPreview: true)
|
||||
store.handlePresenceEventPayload(payload)
|
||||
|
||||
#expect(store.instances.count == 1)
|
||||
let instance = store.instances.first
|
||||
#expect(instance?.host == "gw")
|
||||
#expect(instance?.ip == "10.0.0.1")
|
||||
#expect(instance?.mode == "gateway")
|
||||
#expect(instance?.reason == "test")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct LaunchAgentManagerTests {
|
||||
@Test func `launch at login plist does not keep app alive after manual quit`() throws {
|
||||
let plist = LaunchAgentManager.plistContents(bundlePath: "/Applications/OpenClaw.app")
|
||||
let data = try #require(plist.data(using: .utf8))
|
||||
let object = try #require(
|
||||
PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any])
|
||||
|
||||
#expect(object["RunAtLoad"] as? Bool == true)
|
||||
#expect(object["KeepAlive"] == nil)
|
||||
|
||||
let args = try #require(object["ProgramArguments"] as? [String])
|
||||
#expect(args == ["/Applications/OpenClaw.app/Contents/MacOS/OpenClaw"])
|
||||
}
|
||||
}
|
||||
24
apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift
Normal file
24
apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct LogLocatorTests {
|
||||
@Test func `launchd gateway log path ensures tmp dir exists`() {
|
||||
let fm = FileManager()
|
||||
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let logDir = baseDir.appendingPathComponent("openclaw-tests-\(UUID().uuidString)")
|
||||
|
||||
setenv("OPENCLAW_LOG_DIR", logDir.path, 1)
|
||||
defer {
|
||||
unsetenv("OPENCLAW_LOG_DIR")
|
||||
try? fm.removeItem(at: logDir)
|
||||
}
|
||||
|
||||
_ = LogLocator.launchdGatewayLogPath
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
#expect(fm.fileExists(atPath: logDir.path, isDirectory: &isDir))
|
||||
#expect(isDir.boolValue == true)
|
||||
}
|
||||
}
|
||||
301
apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift
Normal file
301
apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift
Normal file
@@ -0,0 +1,301 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct LowCoverageHelperTests {
|
||||
private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable
|
||||
|
||||
@Test func `any codable helper accessors`() throws {
|
||||
let payload: [String: ProtoAnyCodable] = [
|
||||
"title": ProtoAnyCodable("Hello"),
|
||||
"flag": ProtoAnyCodable(true),
|
||||
"count": ProtoAnyCodable(3),
|
||||
"ratio": ProtoAnyCodable(1.25),
|
||||
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]),
|
||||
]
|
||||
let any = ProtoAnyCodable(payload)
|
||||
let dict = try #require(any.dictionaryValue)
|
||||
#expect(dict["title"]?.stringValue == "Hello")
|
||||
#expect(dict["flag"]?.boolValue == true)
|
||||
#expect(dict["count"]?.intValue == 3)
|
||||
#expect(dict["ratio"]?.doubleValue == 1.25)
|
||||
#expect(dict["list"]?.arrayValue?.count == 2)
|
||||
|
||||
let foundation = any.foundationValue as? [String: Any]
|
||||
#expect((foundation?["title"] as? String) == "Hello")
|
||||
}
|
||||
|
||||
@Test func `attributed string strips foreground color`() {
|
||||
let text = NSMutableAttributedString(string: "Test")
|
||||
text.addAttribute(.foregroundColor, value: NSColor.red, range: NSRange(location: 0, length: 4))
|
||||
let stripped = text.strippingForegroundColor()
|
||||
let color = stripped.attribute(.foregroundColor, at: 0, effectiveRange: nil)
|
||||
#expect(color == nil)
|
||||
}
|
||||
|
||||
@Test func `view metrics reduce width`() {
|
||||
let value = ViewMetricsTesting.reduceWidth(current: 120, next: 180)
|
||||
#expect(value == 180)
|
||||
}
|
||||
|
||||
@Test func `shell executor handles empty command`() async {
|
||||
let result = await ShellExecutor.runDetailed(command: [], cwd: nil, env: nil, timeout: nil)
|
||||
#expect(result.success == false)
|
||||
#expect(result.errorMessage != nil)
|
||||
}
|
||||
|
||||
@Test func `shell executor runs command`() async {
|
||||
let result = await ShellExecutor.runDetailed(command: ["/bin/echo", "ok"], cwd: nil, env: nil, timeout: 2)
|
||||
#expect(result.success == true)
|
||||
#expect(result.stdout.contains("ok") || result.stderr.contains("ok"))
|
||||
}
|
||||
|
||||
@Test func `shell executor times out`() async {
|
||||
let result = await ShellExecutor.runDetailed(command: ["/bin/sleep", "1"], cwd: nil, env: nil, timeout: 0.05)
|
||||
#expect(result.timedOut == true)
|
||||
}
|
||||
|
||||
@Test func `shell executor drains stdout and stderr`() async {
|
||||
let script = """
|
||||
i=0
|
||||
while [ $i -lt 2000 ]; do
|
||||
echo "stdout-$i"
|
||||
echo "stderr-$i" 1>&2
|
||||
i=$((i+1))
|
||||
done
|
||||
"""
|
||||
let result = await ShellExecutor.runDetailed(
|
||||
command: ["/bin/sh", "-c", script],
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 2)
|
||||
#expect(result.success == true)
|
||||
#expect(result.stdout.contains("stdout-1999"))
|
||||
#expect(result.stderr.contains("stderr-1999"))
|
||||
}
|
||||
|
||||
@Test func `node info codable round trip`() throws {
|
||||
let info = NodeInfo(
|
||||
nodeId: "node-1",
|
||||
displayName: "Node One",
|
||||
platform: "macOS",
|
||||
version: "1.0",
|
||||
coreVersion: "1.0-core",
|
||||
uiVersion: "1.0-ui",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "MacBookPro",
|
||||
remoteIp: "192.168.1.2",
|
||||
caps: ["chat"],
|
||||
commands: ["send"],
|
||||
permissions: ["send": true],
|
||||
paired: true,
|
||||
connected: false)
|
||||
let data = try JSONEncoder().encode(info)
|
||||
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data)
|
||||
#expect(decoded.nodeId == "node-1")
|
||||
#expect(decoded.isPaired == true)
|
||||
#expect(decoded.isConnected == false)
|
||||
}
|
||||
|
||||
@Test @MainActor func `presence reporter helpers`() {
|
||||
let summary = PresenceReporter._testComposePresenceSummary(mode: "local", reason: "test")
|
||||
#expect(summary.contains("mode local"))
|
||||
#expect(!PresenceReporter._testAppVersionString().isEmpty)
|
||||
#expect(!PresenceReporter._testPlatformString().isEmpty)
|
||||
_ = PresenceReporter._testLastInputSeconds()
|
||||
_ = PresenceReporter._testPrimaryIPv4Address()
|
||||
}
|
||||
|
||||
@Test func `port guardian parses listeners and builds reports`() {
|
||||
let output = """
|
||||
p123
|
||||
cnode
|
||||
uuser
|
||||
p456
|
||||
cssh
|
||||
uroot
|
||||
"""
|
||||
let listeners = PortGuardian._testParseListeners(output)
|
||||
#expect(listeners.count == 2)
|
||||
#expect(listeners[0].command == "node")
|
||||
#expect(listeners[1].command == "ssh")
|
||||
|
||||
let okReport = PortGuardian._testBuildReport(
|
||||
port: 18789,
|
||||
mode: .local,
|
||||
listeners: [(pid: 1, command: "node", fullCommand: "node", user: "me")])
|
||||
#expect(okReport.offenders.isEmpty)
|
||||
|
||||
let badReport = PortGuardian._testBuildReport(
|
||||
port: 18789,
|
||||
mode: .local,
|
||||
listeners: [(pid: 2, command: "python", fullCommand: "python", user: "me")])
|
||||
#expect(!badReport.offenders.isEmpty)
|
||||
|
||||
let emptyReport = PortGuardian._testBuildReport(port: 18789, mode: .local, listeners: [])
|
||||
#expect(emptyReport.summary.contains("Nothing is listening"))
|
||||
}
|
||||
|
||||
@Test func `port guardian remote mode does not kill docker`() {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "com.docker.backend",
|
||||
fullCommand: "com.docker.backend",
|
||||
port: port, mode: .remote) == true)
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "ssh",
|
||||
fullCommand: "ssh -L \(port):localhost:\(port) user@host",
|
||||
port: port, mode: .remote) == true)
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "podman",
|
||||
fullCommand: "podman",
|
||||
port: port, mode: .remote) == true)
|
||||
}
|
||||
|
||||
@Test func `port guardian local mode still rejects unexpected`() {
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "com.docker.backend",
|
||||
fullCommand: "com.docker.backend",
|
||||
port: 18789, mode: .local) == false)
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "python",
|
||||
fullCommand: "python server.py",
|
||||
port: 18789, mode: .local) == false)
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "node",
|
||||
fullCommand: "openclaw-gateway",
|
||||
port: 18789, mode: .local) == true)
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "node",
|
||||
fullCommand: "node /path/to/gateway-daemon",
|
||||
port: 18789, mode: .local) == true)
|
||||
}
|
||||
|
||||
@Test func `port guardian remote mode report accepts any listener`() {
|
||||
let dockerReport = PortGuardian._testBuildReport(
|
||||
port: 18789, mode: .remote,
|
||||
listeners: [(
|
||||
pid: 99,
|
||||
command: "com.docker.backend",
|
||||
fullCommand: "com.docker.backend",
|
||||
user: "me")])
|
||||
#expect(dockerReport.offenders.isEmpty)
|
||||
|
||||
let localDockerReport = PortGuardian._testBuildReport(
|
||||
port: 18789, mode: .local,
|
||||
listeners: [(
|
||||
pid: 99,
|
||||
command: "com.docker.backend",
|
||||
fullCommand: "com.docker.backend",
|
||||
user: "me")])
|
||||
#expect(!localDockerReport.offenders.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func `canvas scheme handler resolves files and errors`() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let session = root.appendingPathComponent("main", isDirectory: true)
|
||||
try FileManager().createDirectory(at: session, withIntermediateDirectories: true)
|
||||
|
||||
let index = session.appendingPathComponent("index.html")
|
||||
try "<h1>Hello</h1>".write(to: index, atomically: true, encoding: .utf8)
|
||||
|
||||
let handler = CanvasSchemeHandler(root: root)
|
||||
let url = try #require(CanvasScheme.makeURL(session: "main", path: "index.html"))
|
||||
let response = handler._testResponse(for: url)
|
||||
#expect(response.mime == "text/html")
|
||||
#expect(String(data: response.data, encoding: .utf8)?.contains("Hello") == true)
|
||||
|
||||
let invalid = try #require(URL(string: "https://example.com"))
|
||||
let invalidResponse = handler._testResponse(for: invalid)
|
||||
#expect(invalidResponse.mime == "text/html")
|
||||
|
||||
let missing = try #require(CanvasScheme.makeURL(session: "missing", path: "/"))
|
||||
let missingResponse = handler._testResponse(for: missing)
|
||||
#expect(missingResponse.mime == "text/html")
|
||||
|
||||
#expect(handler._testTextEncodingName(for: "text/html") == "utf-8")
|
||||
#expect(handler._testTextEncodingName(for: "application/octet-stream") == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func `canvas scheme handler blocks symlink escapes`() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
let session = root.appendingPathComponent("main", isDirectory: true)
|
||||
try FileManager().createDirectory(at: session, withIntermediateDirectories: true)
|
||||
|
||||
let outside = root.deletingLastPathComponent().appendingPathComponent("canvas-secret-\(UUID().uuidString).txt")
|
||||
defer { try? FileManager().removeItem(at: outside) }
|
||||
try "top-secret".write(to: outside, atomically: true, encoding: .utf8)
|
||||
|
||||
let symlink = session.appendingPathComponent("index.html")
|
||||
try FileManager().createSymbolicLink(at: symlink, withDestinationURL: outside)
|
||||
|
||||
let handler = CanvasSchemeHandler(root: root)
|
||||
let url = try #require(CanvasScheme.makeURL(session: "main", path: "index.html"))
|
||||
let response = handler._testResponse(for: url)
|
||||
let body = String(data: response.data, encoding: .utf8) ?? ""
|
||||
|
||||
#expect(response.mime == "text/html")
|
||||
#expect(body.contains("Forbidden"))
|
||||
#expect(!body.contains("top-secret"))
|
||||
}
|
||||
|
||||
@Test @MainActor func `menu context card injector inserts and finds index`() {
|
||||
let injector = MenuContextCardInjector()
|
||||
let menu = NSMenu()
|
||||
menu.minimumWidth = 280
|
||||
menu.addItem(NSMenuItem(title: "Active", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(NSMenuItem(title: "Quit", action: nil, keyEquivalent: "q"))
|
||||
|
||||
let idx = injector._testFindInsertIndex(in: menu)
|
||||
#expect(idx == 1)
|
||||
#expect(injector._testInitialCardWidth(for: menu) >= 300)
|
||||
|
||||
injector._testSetCache(rows: [SessionRow.previewRows[0]], errorText: nil, updatedAt: Date())
|
||||
injector.menuWillOpen(menu)
|
||||
injector.menuDidClose(menu)
|
||||
|
||||
let fallbackMenu = NSMenu()
|
||||
fallbackMenu.addItem(NSMenuItem(title: "First", action: nil, keyEquivalent: ""))
|
||||
#expect(injector._testFindInsertIndex(in: fallbackMenu) == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func `canvas window helper functions`() throws {
|
||||
#expect(CanvasWindowController._testSanitizeSessionKey(" main ") == "main")
|
||||
#expect(CanvasWindowController._testSanitizeSessionKey("bad/..") == "bad___")
|
||||
#expect(CanvasWindowController._testJSOptionalStringLiteral(nil) == "null")
|
||||
|
||||
let rect = NSRect(x: 10, y: 12, width: 400, height: 420)
|
||||
let key = CanvasWindowController._testStoredFrameKey(sessionKey: "test")
|
||||
let loaded = CanvasWindowController._testStoreAndLoadFrame(sessionKey: "test", frame: rect)
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
#expect(loaded?.size.width == rect.size.width)
|
||||
|
||||
let parsed = CanvasWindowController._testParseIPv4("192.168.1.2")
|
||||
#expect(parsed != nil)
|
||||
if let parsed {
|
||||
#expect(CanvasWindowController._testIsLocalNetworkIPv4(parsed))
|
||||
}
|
||||
|
||||
let url = try #require(URL(string: "http://192.168.1.2"))
|
||||
#expect(CanvasWindowController._testIsLocalNetworkCanvasURL(url))
|
||||
#expect(CanvasWindowController._testParseIPv4("not-an-ip") == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import AppKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct LowCoverageViewSmokeTests {
|
||||
@Test func `context menu card builds body`() {
|
||||
let loading = ContextMenuCardView(rows: [], statusText: "Loading…", isLoading: true)
|
||||
_ = loading.body
|
||||
|
||||
let empty = ContextMenuCardView(rows: [], statusText: nil, isLoading: false)
|
||||
_ = empty.body
|
||||
|
||||
let withRows = ContextMenuCardView(rows: SessionRow.previewRows, statusText: nil, isLoading: false)
|
||||
_ = withRows.body
|
||||
|
||||
let longStatus = "Gateway connection dropped; gateway likely restarted and needs a few seconds to reconnect."
|
||||
_ = ContextRootMenuLabelView(subtitle: longStatus, width: 320).body
|
||||
}
|
||||
|
||||
@Test func `settings toggle row builds body`() {
|
||||
var flag = false
|
||||
let binding = Binding(get: { flag }, set: { flag = $0 })
|
||||
let view = SettingsToggleRow(title: "Enable", subtitle: "Detail", binding: binding)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `voice wake test card builds body across states`() {
|
||||
var state = VoiceWakeTestState.idle
|
||||
var isTesting = false
|
||||
let stateBinding = Binding(get: { state }, set: { state = $0 })
|
||||
let testingBinding = Binding(get: { isTesting }, set: { isTesting = $0 })
|
||||
|
||||
_ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body
|
||||
|
||||
state = .hearing("hello")
|
||||
_ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body
|
||||
|
||||
state = .detected("command")
|
||||
isTesting = true
|
||||
_ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body
|
||||
|
||||
state = .failed("No mic")
|
||||
_ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body
|
||||
}
|
||||
|
||||
@Test func `agent events window builds body with event`() {
|
||||
AgentEventStore.shared.clear()
|
||||
let sample = ControlAgentEvent(
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "tool",
|
||||
ts: Date().timeIntervalSince1970 * 1000,
|
||||
data: ["phase": AnyCodable("start"), "name": AnyCodable("test")],
|
||||
summary: nil)
|
||||
AgentEventStore.shared.append(sample)
|
||||
_ = AgentEventsWindow().body
|
||||
AgentEventStore.shared.clear()
|
||||
}
|
||||
|
||||
@Test func `notify overlay presents and dismisses`() async {
|
||||
let controller = NotifyOverlayController()
|
||||
controller.present(title: "Hello", body: "World", autoDismissAfter: 0)
|
||||
controller.present(title: "Updated", body: "Again", autoDismissAfter: 0)
|
||||
controller.dismiss()
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
}
|
||||
|
||||
@Test func `talk overlay presents twice and dismisses`() async {
|
||||
let controller = TalkOverlayController()
|
||||
controller.present()
|
||||
controller.updateLevel(0.4)
|
||||
controller.present()
|
||||
controller.dismiss()
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
}
|
||||
|
||||
@Test func `visual effect view hosts in NS hosting view`() {
|
||||
let hosting = NSHostingView(rootView: VisualEffectView(material: .sidebar))
|
||||
_ = hosting.fittingSize
|
||||
hosting.rootView = VisualEffectView(material: .popover, emphasized: true)
|
||||
_ = hosting.fittingSize
|
||||
}
|
||||
|
||||
@Test func `menu hosted item hosts content`() {
|
||||
let view = MenuHostedItem(width: 240, rootView: AnyView(Text("Menu")))
|
||||
let hosting = NSHostingView(rootView: view)
|
||||
_ = hosting.fittingSize
|
||||
hosting.rootView = MenuHostedItem(width: 320, rootView: AnyView(Text("Updated")))
|
||||
_ = hosting.fittingSize
|
||||
}
|
||||
|
||||
@Test func `dock icon manager updates visibility`() {
|
||||
_ = NSApplication.shared
|
||||
UserDefaults.standard.set(false, forKey: showDockIconKey)
|
||||
DockIconManager.shared.updateDockVisibility()
|
||||
DockIconManager.shared.temporarilyShowDock()
|
||||
}
|
||||
|
||||
@Test func `voice wake settings exercises helpers`() {
|
||||
VoiceWakeSettings.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func `debug settings exercises helpers`() async {
|
||||
await DebugSettings.exerciseForTesting()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import OpenClawChatUI
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct MacGatewayChatTransportMappingTests {
|
||||
@Test func `snapshot maps to health`() {
|
||||
let snapshot = Snapshot(
|
||||
presence: [],
|
||||
health: OpenClawProtocol.AnyCodable(["ok": OpenClawProtocol.AnyCodable(false)]),
|
||||
stateversion: StateVersion(presence: 1, health: 1),
|
||||
uptimems: 123,
|
||||
configpath: nil,
|
||||
statedir: nil,
|
||||
sessiondefaults: nil,
|
||||
authmode: nil,
|
||||
updateavailable: nil)
|
||||
|
||||
let hello = HelloOk(
|
||||
type: "hello",
|
||||
_protocol: 2,
|
||||
server: [:],
|
||||
features: [:],
|
||||
snapshot: snapshot,
|
||||
pluginsurfaceurls: nil,
|
||||
auth: [:],
|
||||
policy: [:])
|
||||
|
||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
|
||||
switch mapped {
|
||||
case let .health(ok):
|
||||
#expect(ok == false)
|
||||
default:
|
||||
Issue.record("expected .health from snapshot, got \(String(describing: mapped))")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `health event maps to health`() {
|
||||
let frame = EventFrame(
|
||||
type: "event",
|
||||
event: "health",
|
||||
payload: OpenClawProtocol.AnyCodable(["ok": OpenClawProtocol.AnyCodable(true)]),
|
||||
seq: 1,
|
||||
stateversion: nil)
|
||||
|
||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame))
|
||||
switch mapped {
|
||||
case let .health(ok):
|
||||
#expect(ok == true)
|
||||
default:
|
||||
Issue.record("expected .health from health event, got \(String(describing: mapped))")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `tick event maps to tick`() {
|
||||
let frame = EventFrame(type: "event", event: "tick", payload: nil, seq: 1, stateversion: nil)
|
||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame))
|
||||
#expect({
|
||||
if case .tick = mapped { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test func `chat event maps to chat`() {
|
||||
let payload = OpenClawProtocol.AnyCodable([
|
||||
"runId": OpenClawProtocol.AnyCodable("run-1"),
|
||||
"sessionKey": OpenClawProtocol.AnyCodable("main"),
|
||||
"state": OpenClawProtocol.AnyCodable("final"),
|
||||
])
|
||||
let frame = EventFrame(type: "event", event: "chat", payload: payload, seq: 1, stateversion: nil)
|
||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(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 `session message event maps to session message`() {
|
||||
let payload = OpenClawProtocol.AnyCodable([
|
||||
"sessionKey": OpenClawProtocol.AnyCodable("agent:main:main"),
|
||||
"messageId": OpenClawProtocol.AnyCodable("msg-1"),
|
||||
"messageSeq": OpenClawProtocol.AnyCodable(7),
|
||||
"message": OpenClawProtocol.AnyCodable([
|
||||
"role": OpenClawProtocol.AnyCodable("user"),
|
||||
"content": OpenClawProtocol.AnyCodable([
|
||||
OpenClawProtocol.AnyCodable([
|
||||
"type": OpenClawProtocol.AnyCodable("text"),
|
||||
"text": OpenClawProtocol.AnyCodable("spoken transcript"),
|
||||
]),
|
||||
]),
|
||||
"timestamp": OpenClawProtocol.AnyCodable(1234.5),
|
||||
]),
|
||||
])
|
||||
let frame = EventFrame(type: "event", event: "session.message", payload: payload, seq: 1, stateversion: nil)
|
||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame))
|
||||
|
||||
switch mapped {
|
||||
case let .sessionMessage(message):
|
||||
#expect(message.sessionKey == "agent:main:main")
|
||||
#expect(message.messageId == "msg-1")
|
||||
#expect(message.messageSeq == 7)
|
||||
#expect(message.message?.role == "user")
|
||||
#expect(message.message?.content.first?.text == "spoken transcript")
|
||||
default:
|
||||
Issue.record("expected .sessionMessage from session.message event, got \(String(describing: mapped))")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `unknown event maps to nil`() {
|
||||
let frame = EventFrame(
|
||||
type: "event",
|
||||
event: "unknown",
|
||||
payload: OpenClawProtocol.AnyCodable(["a": OpenClawProtocol.AnyCodable(1)]),
|
||||
seq: 1,
|
||||
stateversion: nil)
|
||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame))
|
||||
#expect(mapped == nil)
|
||||
}
|
||||
|
||||
@Test func `seq gap maps to seq gap`() {
|
||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.seqGap(expected: 1, received: 9))
|
||||
#expect({
|
||||
if case .seqGap = mapped { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
}
|
||||
110
apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift
Normal file
110
apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct MacNodeBrowserProxyTests {
|
||||
@Test func `request uses browser control endpoint and wraps result`() async throws {
|
||||
let proxy = MacNodeBrowserProxy(
|
||||
endpointProvider: {
|
||||
MacNodeBrowserProxy.Endpoint(
|
||||
baseURL: URL(string: "http://127.0.0.1:18791")!,
|
||||
token: "test-token",
|
||||
password: nil)
|
||||
},
|
||||
performRequest: { request in
|
||||
#expect(request.url?.absoluteString == "http://127.0.0.1:18791/tabs?profile=work")
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
|
||||
|
||||
let body = Data(#"{"tabs":[{"id":"tab-1"}]}"#.utf8)
|
||||
let url = try #require(request.url)
|
||||
let response = try #require(
|
||||
HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Type": "application/json"]))
|
||||
return (body, response)
|
||||
})
|
||||
|
||||
let payloadJSON = try await proxy.request(
|
||||
paramsJSON: #"{"method":"GET","path":"/tabs","profile":"work"}"#)
|
||||
let payload = try #require(
|
||||
JSONSerialization.jsonObject(with: Data(payloadJSON.utf8)) as? [String: Any])
|
||||
let result = try #require(payload["result"] as? [String: Any])
|
||||
let tabs = try #require(result["tabs"] as? [[String: Any]])
|
||||
|
||||
#expect(payload["files"] == nil)
|
||||
#expect(tabs.count == 1)
|
||||
#expect(tabs[0]["id"] as? String == "tab-1")
|
||||
}
|
||||
|
||||
/// Regression test: nested POST bodies must serialize without __SwiftValue crashes.
|
||||
@Test func `post request serializes nested body without crash`() async throws {
|
||||
actor BodyCapture {
|
||||
private var body: Data?
|
||||
|
||||
func set(_ body: Data?) {
|
||||
self.body = body
|
||||
}
|
||||
|
||||
func get() -> Data? {
|
||||
self.body
|
||||
}
|
||||
}
|
||||
|
||||
let capturedBody = BodyCapture()
|
||||
let proxy = MacNodeBrowserProxy(
|
||||
endpointProvider: {
|
||||
MacNodeBrowserProxy.Endpoint(
|
||||
baseURL: URL(string: "http://127.0.0.1:18791")!,
|
||||
token: nil,
|
||||
password: nil)
|
||||
},
|
||||
performRequest: { request in
|
||||
await capturedBody.set(request.httpBody)
|
||||
let url = try #require(request.url)
|
||||
let response = try #require(
|
||||
HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: nil))
|
||||
return (Data(#"{"ok":true}"#.utf8), response)
|
||||
})
|
||||
|
||||
_ = try await proxy.request(
|
||||
paramsJSON: #"{"method":"POST","path":"/action","body":{"nested":{"key":"val"},"arr":[1,2]}}"#)
|
||||
|
||||
let bodyData = try #require(await capturedBody.get())
|
||||
let parsed = try #require(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
|
||||
let nested = try #require(parsed["nested"] as? [String: Any])
|
||||
#expect(nested["key"] as? String == "val")
|
||||
let arr = try #require(parsed["arr"] as? [Any])
|
||||
#expect(arr.count == 2)
|
||||
}
|
||||
|
||||
@Test func `request reports actionable unavailable when control service is missing`() async throws {
|
||||
let proxy = MacNodeBrowserProxy(
|
||||
endpointProvider: {
|
||||
MacNodeBrowserProxy.Endpoint(
|
||||
baseURL: URL(string: "http://127.0.0.1:18791")!,
|
||||
token: nil,
|
||||
password: nil)
|
||||
},
|
||||
performRequest: { _ in
|
||||
throw URLError(.cannotConnectToHost)
|
||||
})
|
||||
|
||||
do {
|
||||
_ = try await proxy.request(paramsJSON: #"{"method":"GET","path":"/"}"#)
|
||||
Issue.record("request should fail when browser control is unreachable")
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
#expect(message.contains("UNAVAILABLE: macOS app node could not reach the local browser control service"))
|
||||
#expect(message.contains("http://127.0.0.1:18791"))
|
||||
#expect(message.contains("browser control is owned by the CLI node-host"))
|
||||
#expect(message.contains("openclaw node start"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct MacNodeModeCoordinatorTests {
|
||||
@Test @MainActor func `fresh node uses durable dedicated identity for local auto approval`() throws {
|
||||
let defaults = try #require(UserDefaults(suiteName: "MacNodeModeCoordinatorTests.fresh.\(UUID().uuidString)"))
|
||||
|
||||
#expect(MacNodeModeCoordinator.resolveNodeIdentityProfile(
|
||||
defaults: defaults,
|
||||
isExistingInstallation: false) == .node)
|
||||
#expect(MacNodeModeCoordinator.resolveNodeIdentityProfile(
|
||||
defaults: defaults,
|
||||
isExistingInstallation: true) == .node)
|
||||
}
|
||||
|
||||
@Test @MainActor func `upgraded node durably preserves its shipped primary identity`() throws {
|
||||
let defaults = try #require(UserDefaults(suiteName: "MacNodeModeCoordinatorTests.upgrade.\(UUID().uuidString)"))
|
||||
|
||||
#expect(MacNodeModeCoordinator.resolveNodeIdentityProfile(
|
||||
defaults: defaults,
|
||||
isExistingInstallation: true) == .primary)
|
||||
#expect(MacNodeModeCoordinator.resolveNodeIdentityProfile(
|
||||
defaults: defaults,
|
||||
isExistingInstallation: false) == .primary)
|
||||
}
|
||||
|
||||
@Test func `remote mode does not advertise browser proxy`() {
|
||||
let caps = MacNodeModeCoordinator.resolvedCaps(
|
||||
browserControlEnabled: true,
|
||||
cameraEnabled: false,
|
||||
locationMode: .off,
|
||||
connectionMode: .remote)
|
||||
let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps)
|
||||
|
||||
#expect(!caps.contains(OpenClawCapability.browser.rawValue))
|
||||
#expect(!commands.contains(OpenClawBrowserCommand.proxy.rawValue))
|
||||
#expect(commands.contains(OpenClawCanvasCommand.present.rawValue))
|
||||
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
|
||||
}
|
||||
|
||||
@Test func `local mode advertises browser proxy when enabled`() {
|
||||
let caps = MacNodeModeCoordinator.resolvedCaps(
|
||||
browserControlEnabled: true,
|
||||
cameraEnabled: false,
|
||||
locationMode: .off,
|
||||
connectionMode: .local)
|
||||
let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps)
|
||||
|
||||
#expect(caps.contains(OpenClawCapability.browser.rawValue))
|
||||
#expect(commands.contains(OpenClawBrowserCommand.proxy.rawValue))
|
||||
}
|
||||
|
||||
@Test func `tls pin store key uses default wss port`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.ts.net"))
|
||||
#expect(MacNodeModeCoordinator.tlsPinStoreKey(for: url) == "gateway.example.ts.net:443")
|
||||
}
|
||||
|
||||
@Test func `remote tls params prefer configured fingerprint over stored pin`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.com"))
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": "sha256:configured",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let params = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .remote,
|
||||
root: root,
|
||||
storedFingerprint: "stored"))
|
||||
|
||||
#expect(params.expectedFingerprint == "sha256:configured")
|
||||
#expect(params.allowTOFU == false)
|
||||
#expect(params.storeKey == "gateway.example.com:443")
|
||||
}
|
||||
|
||||
@Test func `remote tls params allow first use only when no configured or stored pin exists`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.com"))
|
||||
|
||||
let params = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .remote,
|
||||
root: [:],
|
||||
storedFingerprint: nil))
|
||||
|
||||
#expect(params.expectedFingerprint == nil)
|
||||
#expect(params.allowTOFU == true)
|
||||
}
|
||||
|
||||
@Test func `local tls params ignore remote configured fingerprint`() throws {
|
||||
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": "sha256:remote",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let params = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .local,
|
||||
root: root,
|
||||
storedFingerprint: "stored-local"))
|
||||
|
||||
#expect(params.expectedFingerprint == "stored-local")
|
||||
#expect(params.allowTOFU == false)
|
||||
}
|
||||
|
||||
@Test func `tls session cache reuses session box for unchanged params`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.com"))
|
||||
var cache = MacNodeGatewayTLSSessionCache()
|
||||
let params = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .remote,
|
||||
root: ["gateway": ["remote": ["tlsFingerprint": "sha256:configured"]]],
|
||||
storedFingerprint: "stored"))
|
||||
|
||||
let first = cache.sessionBox(url: url, params: params)
|
||||
let second = cache.sessionBox(url: url, params: params)
|
||||
|
||||
#expect(ObjectIdentifier(first.session) == ObjectIdentifier(second.session))
|
||||
}
|
||||
|
||||
@Test func `tls session cache rebuilds session box when params change`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.com"))
|
||||
var cache = MacNodeGatewayTLSSessionCache()
|
||||
let firstParams = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .remote,
|
||||
root: ["gateway": ["remote": ["tlsFingerprint": "sha256:configured"]]],
|
||||
storedFingerprint: "stored"))
|
||||
let secondParams = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .remote,
|
||||
root: ["gateway": ["remote": ["tlsFingerprint": "sha256:rotated"]]],
|
||||
storedFingerprint: "stored"))
|
||||
|
||||
let first = cache.sessionBox(url: url, params: firstParams)
|
||||
let second = cache.sessionBox(url: url, params: secondParams)
|
||||
|
||||
#expect(ObjectIdentifier(first.session) != ObjectIdentifier(second.session))
|
||||
}
|
||||
|
||||
@Test func `auto repairs trusted tailscale serve pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.ts.net"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "gateway.example.ts.net",
|
||||
storeKey: "gateway.example.ts.net:443",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
|
||||
#expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
|
||||
@Test func `does not auto repair untrusted remote pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.com"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "gateway.example.com",
|
||||
storeKey: "gateway.example.com:443",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
|
||||
#expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
|
||||
@Test func `auto repairs trusted loopback pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "127.0.0.1",
|
||||
storeKey: "127.0.0.1:18789",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
|
||||
#expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
|
||||
@Test func `does not auto repair untrusted loopback pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "127.0.0.1",
|
||||
storeKey: "127.0.0.1:18789",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: false)
|
||||
|
||||
#expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
}
|
||||
602
apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift
Normal file
602
apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift
Normal file
@@ -0,0 +1,602 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct MacNodeRuntimeTests {
|
||||
actor CanvasRefreshProbe {
|
||||
private(set) var calls = 0
|
||||
|
||||
func refresh() -> String? {
|
||||
self.calls += 1
|
||||
return "http://127.0.0.1:18789/refreshed"
|
||||
}
|
||||
}
|
||||
|
||||
actor ExecEventProbe {
|
||||
private var captured: [(event: String, json: String)] = []
|
||||
|
||||
func append(event: String, json: String?) {
|
||||
self.captured.append((event: event, json: json ?? ""))
|
||||
}
|
||||
|
||||
func events() -> [(event: String, json: String)] {
|
||||
self.captured
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ScreenSnapshotProbeServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
|
||||
typealias SnapshotResult = (
|
||||
data: Data,
|
||||
format: OpenClawScreenSnapshotFormat,
|
||||
width: Int,
|
||||
height: Int)
|
||||
|
||||
var snapshotCallCount = 0
|
||||
var receivedSnapshotParams: MacNodeScreenSnapshotParams?
|
||||
var snapshotResult: SnapshotResult
|
||||
var snapshotError: Error?
|
||||
|
||||
init(
|
||||
snapshotResult: SnapshotResult = (Data("ok".utf8), .jpeg, 10, 10),
|
||||
snapshotError: Error? = nil)
|
||||
{
|
||||
self.snapshotResult = snapshotResult
|
||||
self.snapshotError = snapshotError
|
||||
}
|
||||
|
||||
func snapshotScreen(
|
||||
screenIndex: Int?,
|
||||
maxWidth: Int?,
|
||||
quality: Double?,
|
||||
format: OpenClawScreenSnapshotFormat?) async throws -> SnapshotResult
|
||||
{
|
||||
self.snapshotCallCount += 1
|
||||
self.receivedSnapshotParams = MacNodeScreenSnapshotParams(
|
||||
screenIndex: screenIndex,
|
||||
maxWidth: maxWidth,
|
||||
quality: quality,
|
||||
format: format)
|
||||
if let snapshotError {
|
||||
throw snapshotError
|
||||
}
|
||||
return self.snapshotResult
|
||||
}
|
||||
|
||||
func recordScreen(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||
{
|
||||
let url = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-test-screen-record-\(UUID().uuidString).mp4")
|
||||
try Data("ok".utf8).write(to: url)
|
||||
return (path: url.path, hasAudio: false)
|
||||
}
|
||||
|
||||
func locationAuthorizationStatus() -> CLAuthorizationStatus {
|
||||
.authorizedAlways
|
||||
}
|
||||
|
||||
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
.fullAccuracy
|
||||
}
|
||||
|
||||
func currentLocation(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
_ = desiredAccuracy
|
||||
_ = maxAgeMs
|
||||
_ = timeoutMs
|
||||
return CLLocation(latitude: 0, longitude: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects unknown command`() async {
|
||||
let runtime = MacNodeRuntime()
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-1", command: "unknown.command"))
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func `A2UI host capability refresh uses injected node session refresher`() async {
|
||||
let probe = CanvasRefreshProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
canvasSurfaceUrl: { "http://127.0.0.1:18789/current" },
|
||||
refreshCanvasSurfaceUrl: { await probe.refresh() })
|
||||
|
||||
let current = await runtime.resolveA2UIHostUrlWithCapabilityRefresh()
|
||||
#expect(current == "http://127.0.0.1:18789/current/__openclaw__/a2ui/?platform=macos")
|
||||
#expect(await probe.calls == 0)
|
||||
|
||||
let refreshed = await runtime.resolveA2UIHostUrlWithCapabilityRefresh(forceRefresh: true)
|
||||
#expect(refreshed == "http://127.0.0.1:18789/refreshed/__openclaw__/a2ui/?platform=macos")
|
||||
#expect(await probe.calls == 1)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects empty system run`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemRunParams(command: [])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func `system run denied event preserves gateway run id`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
|
||||
let probe = ExecEventProbe()
|
||||
let runtime = MacNodeRuntime()
|
||||
await runtime.setEventSender { event, json in
|
||||
await probe.append(event: event, json: json)
|
||||
}
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/sh", "-lc", "printf ok"],
|
||||
sessionKey: "agent:main:main",
|
||||
runId: "gateway-run-1")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-run-id",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(response.ok == false)
|
||||
let denied = try #require((await probe.events()).first { $0.event == "exec.denied" })
|
||||
struct Payload: Decodable {
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
}
|
||||
let payload = try JSONDecoder().decode(Payload.self, from: Data(denied.json.utf8))
|
||||
#expect(payload.sessionKey == "agent:main:main")
|
||||
#expect(payload.runId == "gateway-run-1")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects blocked system run env override before execution`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/sh", "-lc", "echo ok"],
|
||||
env: ["CLASSPATH": "/tmp/evil-classpath"])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
|
||||
#expect(response.error?.message.contains("CLASSPATH") == true)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects invalid system run env override key before execution`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/sh", "-lc", "echo ok"],
|
||||
env: ["BAD-KEY": "x"])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
|
||||
#expect(response.error?.message.contains("BAD-KEY") == true)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects empty system which`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemWhichParams(bins: [])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2b", command: OpenClawSystemCommand.which.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects empty notification`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemNotifyParams(title: "", body: "")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-3", command: OpenClawSystemCommand.notify.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func `handle invoke camera list requires enabled camera`() async {
|
||||
await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) {
|
||||
let runtime = MacNodeRuntime()
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-4", command: OpenClawCameraCommand.list.rawValue))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("CAMERA_DISABLED") == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `handle invoke screen record uses injected services`() async throws {
|
||||
@MainActor
|
||||
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
|
||||
func snapshotScreen(
|
||||
screenIndex: Int?,
|
||||
maxWidth: Int?,
|
||||
quality: Double?,
|
||||
format: OpenClawScreenSnapshotFormat?) async throws
|
||||
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
|
||||
{
|
||||
_ = screenIndex
|
||||
_ = maxWidth
|
||||
_ = quality
|
||||
return (Data("snapshot".utf8), format ?? .jpeg, 640, 360)
|
||||
}
|
||||
|
||||
func recordScreen(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||
{
|
||||
let url = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-test-screen-record-\(UUID().uuidString).mp4")
|
||||
try Data("ok".utf8).write(to: url)
|
||||
return (path: url.path, hasAudio: false)
|
||||
}
|
||||
|
||||
func locationAuthorizationStatus() -> CLAuthorizationStatus {
|
||||
.authorizedAlways
|
||||
}
|
||||
|
||||
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
.fullAccuracy
|
||||
}
|
||||
|
||||
func currentLocation(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
CLLocation(latitude: 0, longitude: 0)
|
||||
}
|
||||
}
|
||||
|
||||
let services = await MainActor.run { FakeMainActorServices() }
|
||||
let runtime = MacNodeRuntime(makeMainActorServices: { services })
|
||||
|
||||
let params = MacNodeScreenRecordParams(durationMs: 250)
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-5", command: MacNodeScreenCommand.record.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == true)
|
||||
let payloadJSON = try #require(response.payloadJSON)
|
||||
|
||||
struct Payload: Decodable {
|
||||
var format: String
|
||||
var base64: String
|
||||
}
|
||||
let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
|
||||
#expect(payload.format == "mp4")
|
||||
#expect(!payload.base64.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `handle invoke screen snapshot uses injected services`() async throws {
|
||||
@MainActor
|
||||
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
|
||||
var snapshotCalledAtMs: Int64?
|
||||
|
||||
func snapshotScreen(
|
||||
screenIndex: Int?,
|
||||
maxWidth: Int?,
|
||||
quality: Double?,
|
||||
format: OpenClawScreenSnapshotFormat?) async throws
|
||||
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
|
||||
{
|
||||
self.snapshotCalledAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
#expect(screenIndex == 0)
|
||||
#expect(maxWidth == 800)
|
||||
#expect(quality == 0.5)
|
||||
return (Data("ok".utf8), format ?? .jpeg, 800, 450)
|
||||
}
|
||||
|
||||
func recordScreen(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||
{
|
||||
let url = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-test-screen-record-\(UUID().uuidString).mp4")
|
||||
try Data("ok".utf8).write(to: url)
|
||||
return (path: url.path, hasAudio: false)
|
||||
}
|
||||
|
||||
func locationAuthorizationStatus() -> CLAuthorizationStatus {
|
||||
.authorizedAlways
|
||||
}
|
||||
|
||||
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
.fullAccuracy
|
||||
}
|
||||
|
||||
func currentLocation(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
_ = desiredAccuracy
|
||||
_ = maxAgeMs
|
||||
_ = timeoutMs
|
||||
return CLLocation(latitude: 0, longitude: 0)
|
||||
}
|
||||
}
|
||||
|
||||
let services = await MainActor.run { FakeMainActorServices() }
|
||||
let runtime = MacNodeRuntime(makeMainActorServices: { services })
|
||||
|
||||
let params = MacNodeScreenSnapshotParams(
|
||||
screenIndex: 0,
|
||||
maxWidth: 800,
|
||||
quality: 0.5,
|
||||
format: .jpeg)
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-screen-snapshot",
|
||||
command: MacNodeScreenCommand.snapshot.rawValue,
|
||||
paramsJSON: json))
|
||||
#expect(response.ok == true)
|
||||
let payloadJSON = try #require(response.payloadJSON)
|
||||
|
||||
struct Payload: Decodable {
|
||||
var format: String
|
||||
var base64: String
|
||||
var width: Int
|
||||
var height: Int
|
||||
var capturedAtMs: Int64
|
||||
}
|
||||
|
||||
let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
|
||||
#expect(payload.format == "jpeg")
|
||||
#expect(payload.base64 == Data("ok".utf8).base64EncodedString())
|
||||
#expect(payload.width == 800)
|
||||
#expect(payload.height == 450)
|
||||
#expect(payload.capturedAtMs > 0)
|
||||
let snapshotCalledAtMs = await MainActor.run { services.snapshotCalledAtMs }
|
||||
#expect(snapshotCalledAtMs != nil)
|
||||
#expect(payload.capturedAtMs <= snapshotCalledAtMs!)
|
||||
}
|
||||
|
||||
@Test func `handle invoke screen snapshot rejects malformed params before capture`() async throws {
|
||||
let services = await MainActor.run { ScreenSnapshotProbeServices() }
|
||||
let runtime = MacNodeRuntime(makeMainActorServices: { services })
|
||||
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-screen-snapshot-invalid",
|
||||
command: MacNodeScreenCommand.snapshot.rawValue,
|
||||
paramsJSON: #"{"screenIndex":"#))
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.code == .invalidRequest)
|
||||
#expect(response.error?.message == "INVALID_REQUEST: invalid screen snapshot params")
|
||||
let snapshotCallCount = await MainActor.run { services.snapshotCallCount }
|
||||
#expect(snapshotCallCount == 0)
|
||||
}
|
||||
|
||||
@Test func `handle invoke screen snapshot keeps nil params as defaults`() async throws {
|
||||
let services = await MainActor.run { ScreenSnapshotProbeServices() }
|
||||
let runtime = MacNodeRuntime(makeMainActorServices: { services })
|
||||
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-screen-snapshot-defaults",
|
||||
command: MacNodeScreenCommand.snapshot.rawValue))
|
||||
|
||||
#expect(response.ok == true)
|
||||
let received = await MainActor.run { services.receivedSnapshotParams }
|
||||
#expect(received == MacNodeScreenSnapshotParams())
|
||||
}
|
||||
|
||||
@Test func `handle invoke screen snapshot sanitizes capture failures`() async throws {
|
||||
struct SensitiveError: LocalizedError {
|
||||
let detail: String
|
||||
var errorDescription: String? { detail }
|
||||
}
|
||||
|
||||
let services = await MainActor.run {
|
||||
ScreenSnapshotProbeServices(snapshotError: SensitiveError(detail: "TCC_DENIED display-id=ABC123"))
|
||||
}
|
||||
let runtime = MacNodeRuntime(makeMainActorServices: { services })
|
||||
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-screen-snapshot-error",
|
||||
command: MacNodeScreenCommand.snapshot.rawValue))
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.code == .unavailable)
|
||||
#expect(response.error?.message == "UNAVAILABLE: screen snapshot failed")
|
||||
}
|
||||
|
||||
@Test func `handle invoke screen snapshot reports validation failures as invalid request`() async throws {
|
||||
let invalidIndexServices = await MainActor.run {
|
||||
ScreenSnapshotProbeServices(
|
||||
snapshotError: ScreenSnapshotService.ScreenSnapshotError.invalidScreenIndex(4))
|
||||
}
|
||||
let invalidIndexRuntime = MacNodeRuntime(makeMainActorServices: { invalidIndexServices })
|
||||
let invalidIndexResponse = await invalidIndexRuntime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-screen-snapshot-bad-index",
|
||||
command: MacNodeScreenCommand.snapshot.rawValue))
|
||||
|
||||
#expect(invalidIndexResponse.ok == false)
|
||||
#expect(invalidIndexResponse.error?.code == .invalidRequest)
|
||||
#expect(invalidIndexResponse.error?.message == "INVALID_REQUEST: invalid screen index 4")
|
||||
|
||||
let noDisplaysServices = await MainActor.run {
|
||||
ScreenSnapshotProbeServices(snapshotError: ScreenSnapshotService.ScreenSnapshotError.noDisplays)
|
||||
}
|
||||
let noDisplaysRuntime = MacNodeRuntime(makeMainActorServices: { noDisplaysServices })
|
||||
let noDisplaysResponse = await noDisplaysRuntime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-screen-snapshot-no-displays",
|
||||
command: MacNodeScreenCommand.snapshot.rawValue))
|
||||
|
||||
#expect(noDisplaysResponse.ok == false)
|
||||
#expect(noDisplaysResponse.error?.code == .invalidRequest)
|
||||
#expect(
|
||||
noDisplaysResponse.error?.message ==
|
||||
"INVALID_REQUEST: no displays available for screen snapshot")
|
||||
}
|
||||
|
||||
@Test func `handle invoke screen snapshot rejects raw payloads above base64 ceiling`() async throws {
|
||||
let payloadSize = 19_660_801
|
||||
let services = await MainActor.run {
|
||||
ScreenSnapshotProbeServices(snapshotResult: (
|
||||
Data(repeating: 0x41, count: payloadSize),
|
||||
.jpeg,
|
||||
4000,
|
||||
3000))
|
||||
}
|
||||
let runtime = MacNodeRuntime(makeMainActorServices: { services })
|
||||
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-screen-snapshot-too-large",
|
||||
command: MacNodeScreenCommand.snapshot.rawValue))
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.payloadJSON == nil)
|
||||
#expect(response.error?.code == .unavailable)
|
||||
#expect(
|
||||
response.error?.message ==
|
||||
"UNAVAILABLE: screen snapshot payload too large; reduce maxWidth or use jpeg")
|
||||
}
|
||||
|
||||
@Test func `handle invoke screen snapshot rejects escaped oversized outer frames`() async throws {
|
||||
let payloadSize = 12 * 1024 * 1024
|
||||
let services = await MainActor.run {
|
||||
ScreenSnapshotProbeServices(snapshotResult: (
|
||||
Data(repeating: 0xFF, count: payloadSize),
|
||||
.png,
|
||||
4000,
|
||||
3000))
|
||||
}
|
||||
let runtime = MacNodeRuntime(makeMainActorServices: { services })
|
||||
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-screen-snapshot-slash-heavy",
|
||||
command: MacNodeScreenCommand.snapshot.rawValue,
|
||||
nodeId: "node-slash-heavy"))
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.code == .unavailable)
|
||||
#expect(
|
||||
response.error?.message ==
|
||||
"UNAVAILABLE: screen snapshot payload too large; reduce maxWidth or use jpeg")
|
||||
}
|
||||
|
||||
@Test func `handle invoke screen snapshot accepts near-limit frames that fit`() async throws {
|
||||
let payloadSize = 19_660_100
|
||||
let services = await MainActor.run {
|
||||
ScreenSnapshotProbeServices(snapshotResult: (
|
||||
Data(repeating: 0x00, count: payloadSize),
|
||||
.jpeg,
|
||||
4000,
|
||||
3000))
|
||||
}
|
||||
let runtime = MacNodeRuntime(makeMainActorServices: { services })
|
||||
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-fit",
|
||||
command: MacNodeScreenCommand.snapshot.rawValue,
|
||||
nodeId: "node-fit"))
|
||||
|
||||
#expect(response.ok == true)
|
||||
let payloadJSON = try #require(response.payloadJSON)
|
||||
let projected = try MacNodeRuntime.projectedOuterFrameBytes(
|
||||
forPayloadJSON: payloadJSON,
|
||||
requestId: "req-fit",
|
||||
nodeId: "node-fit")
|
||||
#expect(projected < 25 * 1024 * 1024)
|
||||
}
|
||||
|
||||
@Test func `projected outer frame bytes accounts for dynamic node id escaping`() throws {
|
||||
let inner = "{\"format\":\"png\",\"note\":\"\u{0001}\u{0002}\n\t\\\"raw\\\"\",\"width\":1,\"height\":1,\"capturedAtMs\":0}"
|
||||
let projected = try MacNodeRuntime.projectedOuterFrameBytes(
|
||||
forPayloadJSON: inner,
|
||||
requestId: "req-control",
|
||||
nodeId: "node-\u{0001}\u{0002}\u{0003}\n\t-id")
|
||||
|
||||
struct Frame: Encodable {
|
||||
let type = "req"
|
||||
let id = "00000000-0000-0000-0000-000000000000"
|
||||
let method = "node.invoke.result"
|
||||
let params: Params
|
||||
|
||||
struct Params: Encodable {
|
||||
let id: String
|
||||
let nodeId: String
|
||||
let ok: Bool
|
||||
let payloadJSON: String
|
||||
}
|
||||
}
|
||||
let serialized = try JSONEncoder().encode(Frame(params: Frame.Params(
|
||||
id: "req-control",
|
||||
nodeId: "node-\u{0001}\u{0002}\u{0003}\n\t-id",
|
||||
ok: true,
|
||||
payloadJSON: inner)))
|
||||
|
||||
#expect(projected == serialized.count)
|
||||
|
||||
let controlHeavyNodeId = String(repeating: "\u{0001}", count: 5 * 1024 * 1024)
|
||||
let controlHeavyProjection = try MacNodeRuntime.projectedOuterFrameBytes(
|
||||
forPayloadJSON: "{}",
|
||||
requestId: "req-control",
|
||||
nodeId: controlHeavyNodeId)
|
||||
#expect(controlHeavyProjection > 25 * 1024 * 1024)
|
||||
}
|
||||
|
||||
@Test func `handle invoke browser proxy uses injected request`() async {
|
||||
let runtime = MacNodeRuntime(browserProxyRequest: { paramsJSON in
|
||||
#expect(paramsJSON?.contains("/tabs") == true)
|
||||
return #"{"result":{"ok":true,"tabs":[{"id":"tab-1"}]}}"#
|
||||
})
|
||||
let paramsJSON = #"{"method":"GET","path":"/tabs","timeoutMs":2500}"#
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-browser",
|
||||
command: OpenClawBrowserCommand.proxy.rawValue,
|
||||
paramsJSON: paramsJSON))
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.payloadJSON == #"{"result":{"ok":true,"tabs":[{"id":"tab-1"}]}}"#)
|
||||
}
|
||||
|
||||
@Test func `handle invoke browser proxy rejects disabled browser control`() async throws {
|
||||
let override = TestIsolation.tempConfigPath()
|
||||
try await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
||||
try JSONSerialization.data(withJSONObject: ["browser": ["enabled": false]])
|
||||
.write(to: URL(fileURLWithPath: override))
|
||||
|
||||
let runtime = MacNodeRuntime(browserProxyRequest: { _ in
|
||||
Issue.record("browserProxyRequest should not run when browser control is disabled")
|
||||
return "{}"
|
||||
})
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-browser-disabled",
|
||||
command: OpenClawBrowserCommand.proxy.rawValue,
|
||||
paramsJSON: #"{"method":"GET","path":"/tabs"}"#))
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("BROWSER_DISABLED") == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import OpenClawDiscovery
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct MasterDiscoveryMenuSmokeTests {
|
||||
@Test func `inline list builds body when empty`() {
|
||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
discovery.statusText = "Searching…"
|
||||
discovery.gateways = []
|
||||
|
||||
let view = GatewayDiscoveryInlineList(
|
||||
discovery: discovery,
|
||||
currentTarget: nil,
|
||||
currentUrl: nil,
|
||||
transport: .ssh,
|
||||
onSelect: { _ in })
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `inline list builds body with master and selection`() {
|
||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
discovery.statusText = "Found 1"
|
||||
discovery.gateways = [
|
||||
GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Office Mac",
|
||||
lanHost: "office.local",
|
||||
tailnetDns: "office.tailnet-123.ts.net",
|
||||
sshPort: 2222,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "office",
|
||||
debugID: "office",
|
||||
isLocal: false),
|
||||
]
|
||||
|
||||
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
|
||||
let view = GatewayDiscoveryInlineList(
|
||||
discovery: discovery,
|
||||
currentTarget: currentTarget,
|
||||
currentUrl: nil,
|
||||
transport: .ssh,
|
||||
onSelect: { _ in })
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `menu builds body with masters`() {
|
||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
discovery.statusText = "Found 2"
|
||||
discovery.gateways = [
|
||||
GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "A",
|
||||
lanHost: "a.local",
|
||||
tailnetDns: nil,
|
||||
sshPort: 22,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "a",
|
||||
debugID: "a",
|
||||
isLocal: false),
|
||||
GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "B",
|
||||
lanHost: nil,
|
||||
tailnetDns: "b.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "b",
|
||||
debugID: "b",
|
||||
isLocal: false),
|
||||
]
|
||||
|
||||
let view = GatewayDiscoveryMenu(discovery: discovery, onSelect: { _ in })
|
||||
_ = view.body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct MenuContentSmokeTests {
|
||||
@Test func `menu content builds body local mode`() {
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .local
|
||||
let view = MenuContent(state: state, updater: nil)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `menu content builds body remote mode`() {
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .remote
|
||||
let view = MenuContent(state: state, updater: nil)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `menu content builds body unconfigured mode`() {
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .unconfigured
|
||||
let view = MenuContent(state: state, updater: nil)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `menu content builds body with debug and canvas`() {
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .local
|
||||
state.debugPaneEnabled = true
|
||||
state.canvasEnabled = true
|
||||
state.canvasPanelVisible = true
|
||||
state.swabbleEnabled = true
|
||||
state.voicePushToTalkEnabled = true
|
||||
state.heartbeatsEnabled = true
|
||||
let view = MenuContent(state: state, updater: nil)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `dock menu exposes primary shortcuts`() throws {
|
||||
let delegate = AppDelegate()
|
||||
let menu = try #require(delegate.applicationDockMenu(NSApplication.shared))
|
||||
let titles = menu.items.map(\.title)
|
||||
|
||||
#expect(titles.contains("Open Dashboard"))
|
||||
#expect(titles.contains("Open Chat"))
|
||||
#expect(titles.contains("Open Canvas") || titles.contains("Close Canvas"))
|
||||
#expect(titles.contains("Settings…"))
|
||||
}
|
||||
|
||||
@Test func `dock reopen opens dashboard and suppresses default handling`() {
|
||||
let delegate = AppDelegate()
|
||||
var didOpenDashboard = false
|
||||
delegate.openDashboardAction = {
|
||||
didOpenDashboard = true
|
||||
}
|
||||
|
||||
let shouldUseDefaultHandling = delegate.applicationShouldHandleReopen(
|
||||
NSApplication.shared,
|
||||
hasVisibleWindows: false)
|
||||
|
||||
#expect(shouldUseDefaultHandling == false)
|
||||
#expect(didOpenDashboard)
|
||||
}
|
||||
|
||||
@Test func `dock reopen keeps default handling when windows are visible`() {
|
||||
let delegate = AppDelegate()
|
||||
var didOpenDashboard = false
|
||||
delegate.openDashboardAction = {
|
||||
didOpenDashboard = true
|
||||
}
|
||||
|
||||
let shouldUseDefaultHandling = delegate.applicationShouldHandleReopen(
|
||||
NSApplication.shared,
|
||||
hasVisibleWindows: true)
|
||||
|
||||
#expect(shouldUseDefaultHandling)
|
||||
#expect(!didOpenDashboard)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct MenuSessionsInjectorTests {
|
||||
@Test func `anchors dynamic rows below controls and actions`() throws {
|
||||
let injector = MenuSessionsInjector()
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(NSMenuItem(title: "Open Chat", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: ""))
|
||||
|
||||
let footerSeparatorIndex = try #require(menu.items.lastIndex(where: { $0.isSeparatorItem }))
|
||||
#expect(injector.testingFindInsertIndex(in: menu) == footerSeparatorIndex)
|
||||
#expect(injector.testingFindNodesInsertIndex(in: menu) == footerSeparatorIndex)
|
||||
}
|
||||
|
||||
@Test func `injects disconnected message`() {
|
||||
let injector = MenuSessionsInjector()
|
||||
injector.setTestingControlChannelConnected(false)
|
||||
injector.setTestingSnapshot(nil, errorText: nil)
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
|
||||
|
||||
injector.injectForTesting(into: menu)
|
||||
let contextItem = menu.items.first { $0.tag == 9_415_557 && $0.title == "Context" }
|
||||
#expect(contextItem != nil)
|
||||
#expect(contextItem?.submenu != nil)
|
||||
}
|
||||
|
||||
@Test func `injects session rows`() throws {
|
||||
let injector = MenuSessionsInjector()
|
||||
injector.setTestingControlChannelConnected(true)
|
||||
|
||||
let defaults = SessionDefaults(model: "anthropic/claude-opus-4-6", contextTokens: 200_000)
|
||||
let rows = [
|
||||
SessionRow(
|
||||
id: "main",
|
||||
key: "main",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
provider: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date(),
|
||||
sessionId: "s1",
|
||||
thinkingLevel: "low",
|
||||
verboseLevel: nil,
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000),
|
||||
model: "claude-opus-4-6"),
|
||||
SessionRow(
|
||||
id: "discord:group:alpha",
|
||||
key: "discord:group:alpha",
|
||||
kind: .group,
|
||||
displayName: nil,
|
||||
provider: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date(timeIntervalSinceNow: -60),
|
||||
sessionId: "s2",
|
||||
thinkingLevel: "high",
|
||||
verboseLevel: "debug",
|
||||
systemSent: true,
|
||||
abortedLastRun: true,
|
||||
tokens: SessionTokenStats(input: 50, output: 50, total: 100, contextTokens: 200_000),
|
||||
model: "claude-opus-4-6"),
|
||||
]
|
||||
let snapshot = SessionStoreSnapshot(
|
||||
storePath: "/tmp/sessions.json",
|
||||
defaults: defaults,
|
||||
rows: rows)
|
||||
injector.setTestingSnapshot(snapshot, errorText: nil)
|
||||
|
||||
let usage = GatewayUsageSummary(
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
providers: [
|
||||
GatewayUsageProvider(
|
||||
provider: "anthropic",
|
||||
displayName: "Claude",
|
||||
windows: [GatewayUsageWindow(label: "5h", usedPercent: 12, resetAt: nil)],
|
||||
plan: "Pro",
|
||||
error: nil),
|
||||
GatewayUsageProvider(
|
||||
provider: "openai",
|
||||
displayName: "Codex",
|
||||
windows: [GatewayUsageWindow(label: "day", usedPercent: 3, resetAt: nil)],
|
||||
plan: nil,
|
||||
error: nil),
|
||||
])
|
||||
injector.setTestingUsageSummary(usage, errorText: nil)
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: ""))
|
||||
|
||||
injector.injectForTesting(into: menu)
|
||||
let contextItem = try #require(menu.items.first { $0.tag == 9_415_557 && $0.title == "Context" })
|
||||
let contextSubmenu = try #require(contextItem.submenu)
|
||||
#expect(menu.items.count(where: { $0.tag == 9_415_557 && $0.title == "Context" }) == 1)
|
||||
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
|
||||
#expect(contextSubmenu.items.compactMap { $0.representedObject as? String }.count(where: { [
|
||||
"main",
|
||||
"discord:group:alpha",
|
||||
].contains($0) }) == 2)
|
||||
#expect(contextSubmenu.items.allSatisfy { $0.title != "Usage cost (30 days)" })
|
||||
let sendHeartbeatsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }))
|
||||
let openDashboardIndex = try #require(menu.items.firstIndex(where: { $0.title == "Open Dashboard" }))
|
||||
let firstInjectedIndex = try #require(menu.items.firstIndex(where: { $0.tag == 9_415_557 }))
|
||||
let settingsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Settings…" }))
|
||||
#expect(sendHeartbeatsIndex < firstInjectedIndex)
|
||||
#expect(openDashboardIndex < firstInjectedIndex)
|
||||
#expect(firstInjectedIndex < settingsIndex)
|
||||
}
|
||||
|
||||
@Test func `cost usage submenu does not use injector delegate`() {
|
||||
let injector = MenuSessionsInjector()
|
||||
injector.setTestingControlChannelConnected(true)
|
||||
|
||||
let summary = GatewayCostUsageSummary(
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
days: 1,
|
||||
daily: [
|
||||
GatewayCostUsageDay(
|
||||
date: "2026-02-24",
|
||||
input: 10,
|
||||
output: 20,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 30,
|
||||
totalCost: 0.12,
|
||||
missingCostEntries: 0),
|
||||
],
|
||||
totals: GatewayCostUsageTotals(
|
||||
input: 10,
|
||||
output: 20,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 30,
|
||||
totalCost: 0.12,
|
||||
missingCostEntries: 0))
|
||||
injector.setTestingCostUsageSummary(summary, errorText: nil)
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
|
||||
|
||||
injector.injectForTesting(into: menu)
|
||||
|
||||
let contextItem = menu.items.first { $0.tag == 9_415_557 && $0.title == "Context" }
|
||||
#expect(contextItem?.submenu?.items.allSatisfy { $0.title != "Usage cost (30 days)" } == true)
|
||||
let usageCostItem = menu.items.first { $0.title == "Usage cost (30 days)" }
|
||||
#expect(usageCostItem != nil)
|
||||
#expect(usageCostItem?.submenu != nil)
|
||||
#expect(usageCostItem?.submenu?.delegate == nil)
|
||||
}
|
||||
|
||||
@Test func `status text keeps useful error detail`() {
|
||||
let injector = MenuSessionsInjector()
|
||||
let longError = """
|
||||
Gateway connection dropped; gateway likely restarted.
|
||||
Reconnect after the gateway finishes booting.
|
||||
Details that should stay readable instead of collapsing into one tiny menu ellipsis.
|
||||
"""
|
||||
|
||||
let normalized = injector.testingControlChannelStatusText(for: .degraded(longError))
|
||||
|
||||
#expect(normalized.contains("Gateway connection dropped"))
|
||||
#expect(normalized.contains("Reconnect after"))
|
||||
#expect(normalized.count <= 180)
|
||||
#expect(!normalized.contains("\n"))
|
||||
}
|
||||
|
||||
@Test func `node status text distinguishes paired disconnected nodes`() {
|
||||
let pairedDisconnected = Self.node(id: "paired", paired: true, connected: false)
|
||||
let unpairedDisconnected = Self.node(id: "unpaired", paired: false, connected: false)
|
||||
let connected = Self.node(id: "connected", paired: true, connected: true)
|
||||
|
||||
#expect(NodeMenuEntryFormatter.roleText(pairedDisconnected) == "paired · disconnected")
|
||||
#expect(NodeMenuEntryFormatter.roleText(unpairedDisconnected) == "unpaired · disconnected")
|
||||
#expect(NodeMenuEntryFormatter.roleText(connected) == "paired · connected")
|
||||
}
|
||||
|
||||
@Test func `sorted node entries include paired disconnected nodes`() {
|
||||
let injector = MenuSessionsInjector()
|
||||
defer { NodesStore.shared.nodes = [] }
|
||||
NodesStore.shared.nodes = [
|
||||
Self.node(id: "ignored", paired: false, connected: false, displayName: "Ignored"),
|
||||
Self.node(id: "paired", paired: true, connected: false, displayName: "MacBook"),
|
||||
Self.node(id: "connected", paired: true, connected: true, displayName: "iPhone"),
|
||||
]
|
||||
|
||||
let entries = injector.testingSortedNodeEntries()
|
||||
#expect(entries.map(\.nodeId) == ["connected", "paired"])
|
||||
}
|
||||
|
||||
private static func node(
|
||||
id: String,
|
||||
paired: Bool,
|
||||
connected: Bool,
|
||||
displayName: String? = nil) -> NodeInfo
|
||||
{
|
||||
NodeInfo(
|
||||
nodeId: id,
|
||||
displayName: displayName ?? id,
|
||||
platform: "macOS 26.3.1",
|
||||
version: nil,
|
||||
coreVersion: nil,
|
||||
uiVersion: nil,
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: nil,
|
||||
remoteIp: nil,
|
||||
caps: nil,
|
||||
commands: nil,
|
||||
permissions: nil,
|
||||
paired: paired,
|
||||
connected: connected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct NixModeStableSuiteTests {
|
||||
@Test func `resolves from stable suite for app bundles`() throws {
|
||||
let suite = try #require(UserDefaults(suiteName: launchdLabel))
|
||||
let key = "openclaw.nixMode"
|
||||
let prev = suite.object(forKey: key)
|
||||
defer {
|
||||
if let prev { suite.set(prev, forKey: key) } else { suite.removeObject(forKey: key) }
|
||||
}
|
||||
|
||||
suite.set(true, forKey: key)
|
||||
|
||||
let standard = try #require(UserDefaults(suiteName: "NixModeStableSuiteTests.\(UUID().uuidString)"))
|
||||
#expect(!standard.bool(forKey: key))
|
||||
|
||||
let resolved = ProcessInfo.resolveNixMode(
|
||||
environment: [:],
|
||||
standard: standard,
|
||||
stableSuite: suite,
|
||||
isAppBundle: true)
|
||||
#expect(resolved)
|
||||
}
|
||||
|
||||
@Test func `ignores stable suite outside app bundles`() throws {
|
||||
let suite = try #require(UserDefaults(suiteName: launchdLabel))
|
||||
let key = "openclaw.nixMode"
|
||||
let prev = suite.object(forKey: key)
|
||||
defer {
|
||||
if let prev { suite.set(prev, forKey: key) } else { suite.removeObject(forKey: key) }
|
||||
}
|
||||
|
||||
suite.set(true, forKey: key)
|
||||
let standard = try #require(UserDefaults(suiteName: "NixModeStableSuiteTests.\(UUID().uuidString)"))
|
||||
|
||||
let resolved = ProcessInfo.resolveNixMode(
|
||||
environment: [:],
|
||||
standard: standard,
|
||||
stableSuite: suite,
|
||||
isAppBundle: false)
|
||||
#expect(!resolved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct NodeManagerPathsTests {
|
||||
@Test func `fnm node bins prefer newest installed version`() throws {
|
||||
let home = try makeTempDirForTests()
|
||||
|
||||
let v20Bin = home
|
||||
.appendingPathComponent(".local/share/fnm/node-versions/v20.19.5/installation/bin/node")
|
||||
let v25Bin = home
|
||||
.appendingPathComponent(".local/share/fnm/node-versions/v25.1.0/installation/bin/node")
|
||||
try makeExecutableForTests(at: v20Bin)
|
||||
try makeExecutableForTests(at: v25Bin)
|
||||
|
||||
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
|
||||
#expect(bins.first == v25Bin.deletingLastPathComponent().path)
|
||||
#expect(bins.contains(v20Bin.deletingLastPathComponent().path))
|
||||
}
|
||||
|
||||
@Test func `ignores entries without node executable`() throws {
|
||||
let home = try makeTempDirForTests()
|
||||
let missingNodeBin = home
|
||||
.appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin")
|
||||
try FileManager().createDirectory(at: missingNodeBin, withIntermediateDirectories: true)
|
||||
|
||||
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
|
||||
#expect(!bins.contains(missingNodeBin.path))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct NodePairingApprovalPrompterTests {
|
||||
@Test func `silent pairing requires a trusted SSH host key`() {
|
||||
let options = NodePairingApprovalPrompter._testSilentPairingSSHOptions()
|
||||
|
||||
#expect(options.contains("BatchMode=yes"))
|
||||
#expect(options.contains("ControlMaster=no"))
|
||||
#expect(options.contains("ControlPath=none"))
|
||||
#expect(options.contains("ControlPersist=no"))
|
||||
#expect(options.contains("ForkAfterAuthentication=no"))
|
||||
#expect(options.contains("StrictHostKeyChecking=yes"))
|
||||
#expect(!options.contains("StrictHostKeyChecking=accept-new"))
|
||||
}
|
||||
|
||||
@Test func `own node is automatically approved only for a local gateway`() {
|
||||
#expect(NodePairingApprovalPrompter.shouldAutoApproveOwnLocalNode(
|
||||
connectionMode: .local,
|
||||
requestNodeId: "node-1",
|
||||
localNodeId: "node-1"))
|
||||
#expect(!NodePairingApprovalPrompter.shouldAutoApproveOwnLocalNode(
|
||||
connectionMode: .remote,
|
||||
requestNodeId: "node-1",
|
||||
localNodeId: "node-1"))
|
||||
#expect(!NodePairingApprovalPrompter.shouldAutoApproveOwnLocalNode(
|
||||
connectionMode: .local,
|
||||
requestNodeId: "node-2",
|
||||
localNodeId: "node-1"))
|
||||
}
|
||||
|
||||
@Test func `node pairing approval prompter exercises`() async {
|
||||
await NodePairingApprovalPrompter.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func `pairing alert makes approve the primary action`() {
|
||||
let alert = NSAlert()
|
||||
PairingAlertSupport.configureDefaultPairingAlert(
|
||||
alert,
|
||||
messageText: "New Mac wants to connect",
|
||||
informativeText: "Approve this Mac app to control OpenClaw.",
|
||||
buttonTitles: PairingAlertSupport.ButtonTitles(approve: "Approve Mac"))
|
||||
|
||||
#expect(alert.alertStyle == .informational)
|
||||
#expect(alert.buttons.map(\.title) == ["Approve Mac", "Not Now", "Reject"])
|
||||
if #available(macOS 11.0, *) {
|
||||
#expect(alert.buttons[2].hasDestructiveAction)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `device pairing copy summarizes Mac requests`() {
|
||||
let request = DevicePairingApprovalPrompter.PendingRequest(
|
||||
requestId: "req-1",
|
||||
deviceId: "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd",
|
||||
publicKey: "pub",
|
||||
displayName: nil,
|
||||
platform: "MacIntel",
|
||||
clientId: nil,
|
||||
clientMode: nil,
|
||||
role: "operator",
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
],
|
||||
remoteIp: "192.0.2.10",
|
||||
silent: nil,
|
||||
isRepair: nil,
|
||||
ts: 1)
|
||||
|
||||
#expect(DevicePairingApprovalPrompter.alertTitle(for: request) == "New Mac wants to connect")
|
||||
#expect(DevicePairingApprovalPrompter.approveButtonTitle(for: request) == "Approve Mac")
|
||||
#expect(DevicePairingApprovalPrompter.deviceName(for: request) == "OpenClaw Mac app")
|
||||
#expect(DevicePairingApprovalPrompter.prettyPlatform(request.platform) == "Mac (Intel)")
|
||||
#expect(DevicePairingApprovalPrompter.shortIdentifier(request.deviceId) == "4a865684...f4e82bd")
|
||||
#expect(DevicePairingApprovalPrompter.friendlyScopeNames(request.scopes) == [
|
||||
"Admin access",
|
||||
"Read OpenClaw data",
|
||||
"Send messages and make changes",
|
||||
"Manage approvals",
|
||||
"Pair and repair devices",
|
||||
])
|
||||
#expect(!DevicePairingApprovalPrompter.alertSummary(for: request).contains(request.deviceId))
|
||||
|
||||
let accessory = DevicePairingApprovalPrompter.buildAccessoryView(for: request)
|
||||
#expect(accessory.frame.width >= 380)
|
||||
#expect(accessory.frame.height > 80)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct NodePairingReconcilePolicyTests {
|
||||
@Test func `policy polls only when active`() {
|
||||
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: false) == false)
|
||||
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 1, isPresenting: false))
|
||||
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: true))
|
||||
}
|
||||
|
||||
@Test func `policy uses slow safety interval`() {
|
||||
#expect(NodePairingReconcilePolicy.activeIntervalMs >= 10000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct NodeServiceManagerTests {
|
||||
@Test func `builds node service commands with current CLI shape`() async throws {
|
||||
try await TestIsolation.withUserDefaultsValues(["openclaw.gatewayProjectRootPath": nil]) {
|
||||
let tmp = try makeTempDirForTests()
|
||||
CommandResolver.setProjectRoot(tmp.path)
|
||||
|
||||
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
|
||||
try makeExecutableForTests(at: openclawPath)
|
||||
|
||||
let start = NodeServiceManager._testServiceCommand(["start"])
|
||||
#expect(start == [openclawPath.path, "node", "start", "--json"])
|
||||
|
||||
let stop = NodeServiceManager._testServiceCommand(["stop"])
|
||||
#expect(stop == [openclawPath.path, "node", "stop", "--json"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct OnboardingCoverageTests {
|
||||
@Test func `exercise onboarding pages`() {
|
||||
OnboardingView.exerciseForTesting()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
struct OnboardingRemoteAuthPromptTests {
|
||||
@Test func `auth detail codes map to remote auth issues`() {
|
||||
let tokenMissing = GatewayConnectAuthError(
|
||||
message: "token missing",
|
||||
detailCode: GatewayConnectAuthDetailCode.authTokenMissing.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let tokenMismatch = GatewayConnectAuthError(
|
||||
message: "token mismatch",
|
||||
detailCode: GatewayConnectAuthDetailCode.authTokenMismatch.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let tokenNotConfigured = GatewayConnectAuthError(
|
||||
message: "token not configured",
|
||||
detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let bootstrapInvalid = GatewayConnectAuthError(
|
||||
message: "setup code expired",
|
||||
detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let passwordMissing = GatewayConnectAuthError(
|
||||
message: "password missing",
|
||||
detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let pairingRequired = GatewayConnectAuthError(
|
||||
message: "pairing required",
|
||||
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let unknown = GatewayConnectAuthError(
|
||||
message: "other",
|
||||
detailCode: "SOMETHING_ELSE",
|
||||
canRetryWithDeviceToken: false)
|
||||
|
||||
#expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired)
|
||||
#expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch)
|
||||
#expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured)
|
||||
#expect(RemoteGatewayAuthIssue(error: bootstrapInvalid) == .setupCodeExpired)
|
||||
#expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired)
|
||||
#expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired)
|
||||
#expect(RemoteGatewayAuthIssue(error: unknown) == nil)
|
||||
}
|
||||
|
||||
@Test func `password detail family maps to password required issue`() {
|
||||
let mismatch = GatewayConnectAuthError(
|
||||
message: "password mismatch",
|
||||
detailCode: GatewayConnectAuthDetailCode.authPasswordMismatch.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
let notConfigured = GatewayConnectAuthError(
|
||||
message: "password not configured",
|
||||
detailCode: GatewayConnectAuthDetailCode.authPasswordNotConfigured.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
|
||||
#expect(RemoteGatewayAuthIssue(error: mismatch) == .passwordRequired)
|
||||
#expect(RemoteGatewayAuthIssue(error: notConfigured) == .passwordRequired)
|
||||
}
|
||||
|
||||
@Test func `token field visibility follows onboarding rules`() {
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: nil) == false)
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: true,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: nil))
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "secret",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: nil))
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: true,
|
||||
authIssue: nil))
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: .tokenRequired))
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: .tokenMismatch))
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: .gatewayTokenNotConfigured) == false)
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: .setupCodeExpired) == false)
|
||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||
showAdvancedConnection: false,
|
||||
remoteToken: "",
|
||||
remoteTokenUnsupported: false,
|
||||
authIssue: .pairingRequired) == false)
|
||||
}
|
||||
|
||||
@Test func `pairing required copy points users to pair approve`() {
|
||||
let issue = RemoteGatewayAuthIssue.pairingRequired
|
||||
|
||||
#expect(issue.title == "This device needs pairing approval")
|
||||
#expect(issue.body.contains("`/pair approve`"))
|
||||
#expect(issue.statusMessage.contains("/pair approve"))
|
||||
#expect(issue.footnote?.contains("`openclaw devices approve`") == true)
|
||||
}
|
||||
|
||||
@Test func `paired device success copy explains auth source`() {
|
||||
let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken)
|
||||
let bootstrap = RemoteGatewayProbeSuccess(authSource: .bootstrapToken)
|
||||
let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken)
|
||||
let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none)
|
||||
|
||||
#expect(pairedDevice.title == "Connected via paired device")
|
||||
#expect(pairedDevice
|
||||
.detail == "This Mac used a stored device token. New or unpaired devices may still need the gateway token.")
|
||||
#expect(bootstrap.title == "Connected with setup code")
|
||||
#expect(bootstrap
|
||||
.detail ==
|
||||
"This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth.")
|
||||
#expect(sharedToken.title == "Connected with gateway token")
|
||||
#expect(sharedToken.detail == nil)
|
||||
#expect(noAuth.title == "Remote gateway ready")
|
||||
#expect(noAuth.detail == nil)
|
||||
}
|
||||
|
||||
@Test func `transient probe mode restore does not clear probe feedback`() {
|
||||
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .local, suppressReset: false))
|
||||
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .unconfigured, suppressReset: false))
|
||||
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .remote, suppressReset: false) == false)
|
||||
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .local, suppressReset: true) == false)
|
||||
}
|
||||
}
|
||||
164
apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift
Normal file
164
apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
import OpenClawDiscovery
|
||||
import OpenClawIPC
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct OnboardingViewSmokeTests {
|
||||
@Test func `onboarding view builds body`() {
|
||||
let state = AppState(preview: true)
|
||||
let view = OnboardingView(
|
||||
state: state,
|
||||
permissionMonitor: PermissionMonitor.shared,
|
||||
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `page order omits workspace and identity steps`() {
|
||||
let order = OnboardingView.pageOrder(
|
||||
for: .local,
|
||||
showOnboardingChat: false,
|
||||
requiresCLIInstall: false)
|
||||
#expect(!order.contains(7))
|
||||
#expect(order.contains(3))
|
||||
}
|
||||
|
||||
@Test func `page order omits onboarding chat when identity known`() {
|
||||
let order = OnboardingView.pageOrder(
|
||||
for: .local,
|
||||
showOnboardingChat: false,
|
||||
requiresCLIInstall: false)
|
||||
#expect(!order.contains(8))
|
||||
}
|
||||
|
||||
@Test func `fresh local setup installs CLI before the Crestodian chat`() {
|
||||
let order = OnboardingView.pageOrder(
|
||||
for: .local,
|
||||
showOnboardingChat: false,
|
||||
requiresCLIInstall: true)
|
||||
|
||||
#expect(order.firstIndex(of: 2) == 2)
|
||||
#expect(order.firstIndex(of: 3) == 3)
|
||||
}
|
||||
|
||||
@Test func `configured local setup skips CLI install page`() {
|
||||
let order = OnboardingView.pageOrder(
|
||||
for: .local,
|
||||
showOnboardingChat: false,
|
||||
requiresCLIInstall: false)
|
||||
|
||||
#expect(!order.contains(2))
|
||||
}
|
||||
|
||||
@Test func `fresh onboarding defaults to this Mac`() {
|
||||
let state = AppState(preview: true)
|
||||
state.onboardingSeen = false
|
||||
state.connectionMode = .unconfigured
|
||||
let view = OnboardingView(state: state)
|
||||
|
||||
#expect(view.selectedConnectionMode == .local)
|
||||
#expect(view.isConnectionSelectionBlocking)
|
||||
#expect(state.connectionMode == .unconfigured)
|
||||
}
|
||||
|
||||
@Test func `reopened onboarding preserves configure later selection`() {
|
||||
let state = AppState(preview: true)
|
||||
state.onboardingSeen = true
|
||||
state.connectionMode = .unconfigured
|
||||
let view = OnboardingView(state: state)
|
||||
|
||||
#expect(view.selectedConnectionMode == .unconfigured)
|
||||
#expect(!view.isConnectionSelectionBlocking)
|
||||
#expect(state.connectionMode == .unconfigured)
|
||||
}
|
||||
|
||||
@Test func `advancing from recommended this Mac commits local mode`() {
|
||||
let state = AppState(preview: true)
|
||||
state.onboardingSeen = false
|
||||
state.connectionMode = .unconfigured
|
||||
let view = OnboardingView(state: state)
|
||||
|
||||
view.commitRecommendedConnectionIfNeeded(for: view.connectionPageIndex)
|
||||
|
||||
#expect(state.connectionMode == .local)
|
||||
}
|
||||
|
||||
@Test func `automatic CLI setup waits for the initial status probe`() {
|
||||
#expect(!OnboardingView.shouldAutoInstallCLI(
|
||||
onCLIPage: true,
|
||||
isLocal: true,
|
||||
visible: true,
|
||||
statusKnown: false,
|
||||
installed: false,
|
||||
installing: false))
|
||||
#expect(OnboardingView.shouldAutoInstallCLI(
|
||||
onCLIPage: true,
|
||||
isLocal: true,
|
||||
visible: true,
|
||||
statusKnown: true,
|
||||
installed: false,
|
||||
installing: false))
|
||||
#expect(!OnboardingView.shouldAutoInstallCLI(
|
||||
onCLIPage: true,
|
||||
isLocal: true,
|
||||
visible: false,
|
||||
statusKnown: true,
|
||||
installed: false,
|
||||
installing: false))
|
||||
}
|
||||
|
||||
@Test func `Crestodian setup requires a nonempty auth value`() {
|
||||
#expect(!OnboardingView.hasCrestodianSetupAuth([:]))
|
||||
#expect(!OnboardingView.hasCrestodianSetupAuth(["token": " "]))
|
||||
#expect(OnboardingView.hasCrestodianSetupAuth(["mode": "token"]))
|
||||
#expect(OnboardingView.hasCrestodianSetupAuth([
|
||||
"token": ["source": "env", "provider": "default", "id": "GATEWAY_TOKEN"],
|
||||
]))
|
||||
}
|
||||
|
||||
@Test func `select remote gateway clears stale ssh target when endpoint unresolved`() async {
|
||||
let override = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("openclaw.json")
|
||||
.path
|
||||
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
||||
let state = AppState(preview: true)
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteTarget = "user@old-host:2222"
|
||||
let view = OnboardingView(
|
||||
state: state,
|
||||
permissionMonitor: PermissionMonitor.shared,
|
||||
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Unresolved",
|
||||
serviceHost: nil,
|
||||
servicePort: nil,
|
||||
lanHost: "txt-host.local",
|
||||
tailnetDns: "txt-host.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 18789,
|
||||
cliPath: "/tmp/openclaw",
|
||||
stableID: UUID().uuidString,
|
||||
debugID: UUID().uuidString,
|
||||
isLocal: false)
|
||||
|
||||
view.selectRemoteGateway(gateway)
|
||||
#expect(state.remoteTarget.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `permission list covers every capability in importance order`() {
|
||||
#expect(Set(Capability.importanceOrdered) == Set(Capability.allCases))
|
||||
#expect(Capability.importanceOrdered.count == Capability.allCases.count)
|
||||
// App control and context capture lead; location stays last.
|
||||
#expect(Capability.importanceOrdered.first == .appleScript)
|
||||
#expect(Array(Capability.importanceOrdered.prefix(3))
|
||||
== [.appleScript, .accessibility, .screenRecording])
|
||||
#expect(Capability.importanceOrdered.last == Capability.location)
|
||||
}
|
||||
}
|
||||
508
apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift
Normal file
508
apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift
Normal file
@@ -0,0 +1,508 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct OpenClawConfigFileTests {
|
||||
private func makeConfigOverridePath() -> String {
|
||||
FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("openclaw.json")
|
||||
.path
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config path respects env override`() async {
|
||||
let override = self.makeConfigOverridePath()
|
||||
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
||||
#expect(OpenClawConfigFile.url().path == override)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `remote gateway port parses and matches host`() async {
|
||||
let override = self.makeConfigOverridePath()
|
||||
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://gateway.ts.net:19999",
|
||||
],
|
||||
],
|
||||
])
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort() == 19999)
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway.ts.net") == 19999)
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "GATEWAY.ts.net.") == 19999)
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway") == nil)
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil)
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway.attacker.tld") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `set remote gateway url string replaces scheme`() async {
|
||||
let override = self.makeConfigOverridePath()
|
||||
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "wss://old-host:111",
|
||||
],
|
||||
],
|
||||
])
|
||||
OpenClawConfigFile.setRemoteGatewayUrlString("ws://127.0.0.1:18789")
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String
|
||||
#expect(url == "ws://127.0.0.1:18789")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `set remote gateway url preserves scheme`() async {
|
||||
let override = self.makeConfigOverridePath()
|
||||
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "wss://old-host:111",
|
||||
],
|
||||
],
|
||||
])
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(host: "new-host", port: 2222)
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String
|
||||
#expect(url == "wss://new-host:2222")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `clear remote gateway url removes only url field`() async {
|
||||
let override = self.makeConfigOverridePath()
|
||||
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "wss://old-host:111",
|
||||
"token": "tok",
|
||||
],
|
||||
],
|
||||
])
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let remote = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
|
||||
#expect((remote["url"] as? String) == nil)
|
||||
#expect((remote["token"] as? String) == "tok")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `state dir override sets config path`() async {
|
||||
let dir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
.path
|
||||
|
||||
await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_CONFIG_PATH": nil,
|
||||
"OPENCLAW_STATE_DIR": dir,
|
||||
]) {
|
||||
#expect(OpenClawConfigFile.stateDirURL().path == dir)
|
||||
#expect(OpenClawConfigFile.url().path == "\(dir)/openclaw.json")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `save dict appends config audit log`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": ["mode": "local"],
|
||||
])
|
||||
|
||||
let configData = try Data(contentsOf: configPath)
|
||||
let configRoot = try JSONSerialization.jsonObject(with: configData) as? [String: Any]
|
||||
#expect((configRoot?["meta"] as? [String: Any]) != nil)
|
||||
|
||||
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
|
||||
let lines = rawAudit
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map(String.init)
|
||||
#expect(!lines.isEmpty)
|
||||
guard let last = lines.last else {
|
||||
Issue.record("Missing config audit line")
|
||||
return
|
||||
}
|
||||
let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any]
|
||||
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
|
||||
#expect(auditRoot?["event"] as? String == "config.write")
|
||||
#expect(auditRoot?["result"] as? String == "success")
|
||||
#expect(auditRoot?["configPath"] as? String == configPath.path)
|
||||
#expect(auditRoot?["previousMode"] is NSNull)
|
||||
#expect(auditRoot?["nextMode"] is NSNumber)
|
||||
#expect(auditRoot?["previousIno"] is NSNull)
|
||||
#expect(auditRoot?["nextIno"] as? String != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `save dict preserves gateway auth unless explicitly allowed`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"auth": [
|
||||
"mode": "token",
|
||||
"token": "existing-token", // pragma: allowlist secret
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "local",
|
||||
],
|
||||
])
|
||||
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let auth = gateway?["auth"] as? [String: Any]
|
||||
#expect(gateway?["mode"] as? String == "local")
|
||||
#expect(auth?["mode"] as? String == "token")
|
||||
#expect(auth?["token"] as? String == "existing-token") // pragma: allowlist secret
|
||||
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "local",
|
||||
],
|
||||
], allowGatewayAuthMutation: true)
|
||||
|
||||
let allowedRoot = OpenClawConfigFile.loadDict()
|
||||
let allowedGateway = allowedRoot["gateway"] as? [String: Any]
|
||||
#expect(allowedGateway?["mode"] as? String == "local")
|
||||
#expect((allowedGateway?["auth"] as? [String: Any]) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `save dict can merge local fallback writes with fresh config`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"auth": [
|
||||
"mode": "password",
|
||||
"password": "existing-password", // pragma: allowlist secret
|
||||
],
|
||||
],
|
||||
"browser": [
|
||||
"enabled": true,
|
||||
"profile": "work",
|
||||
],
|
||||
"channels": [
|
||||
"discord": [
|
||||
"enabled": true,
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "local",
|
||||
],
|
||||
"browser": [
|
||||
"enabled": false,
|
||||
],
|
||||
], preserveExistingKeys: true)
|
||||
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let auth = gateway?["auth"] as? [String: Any]
|
||||
let browser = root["browser"] as? [String: Any]
|
||||
let discord = ((root["channels"] as? [String: Any])?["discord"] as? [String: Any])
|
||||
#expect(gateway?["mode"] as? String == "local")
|
||||
#expect(auth?["mode"] as? String == "password")
|
||||
#expect(auth?["password"] as? String == "existing-password") // pragma: allowlist secret
|
||||
#expect(browser?["enabled"] as? Bool == false)
|
||||
#expect(browser?["profile"] as? String == "work")
|
||||
#expect(discord?["enabled"] as? Bool == true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `load dict ignores legacy config health sidecar`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
let configHealthPath = stateDir.appendingPathComponent("logs/config-health.json")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try FileManager().createDirectory(
|
||||
at: configHealthPath.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let legacyHealth = """
|
||||
{
|
||||
"entries": {
|
||||
"\(configPath.path)": {
|
||||
"lastKnownGood": {
|
||||
"bytes": 4096,
|
||||
"gatewayMode": "local",
|
||||
"hasMeta": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try legacyHealth.write(to: configHealthPath, atomically: true, encoding: .utf8)
|
||||
let updateOnlyConfig = """
|
||||
{
|
||||
"update": {
|
||||
"channel": "beta"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try updateOnlyConfig.write(to: configPath, atomically: true, encoding: .utf8)
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
try OpenClawConfigFile.withTestingFileLock {
|
||||
let loaded = OpenClawConfigFile.loadDict()
|
||||
let update = loaded["update"] as? [String: Any]
|
||||
#expect(update?["channel"] as? String == "beta")
|
||||
#expect(!FileManager().fileExists(atPath: auditPath.path))
|
||||
let persistedHealth = try String(contentsOf: configHealthPath, encoding: .utf8)
|
||||
#expect(persistedHealth == legacyHealth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `load dict audits suspicious out-of-band clobbers`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
let configHealthPath = stateDir.appendingPathComponent("logs/config-health.json")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
try OpenClawConfigFile.withTestingFileLock {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"update": ["channel": "beta"],
|
||||
"browser": ["enabled": true],
|
||||
"gateway": ["mode": "local"],
|
||||
"channels": [
|
||||
"discord": [
|
||||
"enabled": true,
|
||||
"dmPolicy": "pairing",
|
||||
],
|
||||
],
|
||||
])
|
||||
_ = OpenClawConfigFile.loadDict()
|
||||
#expect(!FileManager().fileExists(atPath: configHealthPath.path))
|
||||
|
||||
let clobbered = """
|
||||
{
|
||||
"update": {
|
||||
"channel": "beta"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try clobbered.write(to: configPath, atomically: true, encoding: .utf8)
|
||||
|
||||
let loaded = OpenClawConfigFile.loadDict()
|
||||
#expect((loaded["gateway"] as? [String: Any]) == nil)
|
||||
#expect(!FileManager().fileExists(atPath: configHealthPath.path))
|
||||
|
||||
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
|
||||
let lines = rawAudit
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map(String.init)
|
||||
let observeLine = lines.reversed().first { $0.contains("\"event\":\"config.observe\"") }
|
||||
#expect(observeLine != nil)
|
||||
guard let observeLine else {
|
||||
Issue.record("Missing config.observe audit line")
|
||||
return
|
||||
}
|
||||
let auditRoot = try JSONSerialization.jsonObject(with: Data(observeLine.utf8)) as? [String: Any]
|
||||
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
|
||||
#expect(auditRoot?["configPath"] as? String == configPath.path)
|
||||
#expect(auditRoot?["mode"] is NSNumber)
|
||||
#expect(auditRoot?["ino"] as? String != nil)
|
||||
#expect(auditRoot?["lastKnownGoodMode"] is NSNumber)
|
||||
#expect(auditRoot?["backupMode"] is NSNull)
|
||||
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
|
||||
#expect(suspicious.contains("gateway-mode-missing-vs-last-good"))
|
||||
#expect(suspicious.contains("update-channel-only-root"))
|
||||
|
||||
let clobberedPath = auditRoot?["clobberedPath"] as? String
|
||||
#expect(clobberedPath != nil)
|
||||
if let clobberedPath {
|
||||
let preserved = try String(contentsOfFile: clobberedPath, encoding: .utf8)
|
||||
#expect(preserved == clobbered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `save dict records preserved gateway auth in audit`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "local",
|
||||
"auth": [
|
||||
"mode": "token",
|
||||
"token": "test-token", // pragma: allowlist secret
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
let saved = OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "local",
|
||||
],
|
||||
"browser": [
|
||||
"enabled": false,
|
||||
],
|
||||
])
|
||||
|
||||
#expect(saved)
|
||||
let data = try Data(contentsOf: configPath)
|
||||
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let gateway = root?["gateway"] as? [String: Any]
|
||||
let auth = gateway?["auth"] as? [String: Any]
|
||||
#expect(gateway?["mode"] as? String == "local")
|
||||
#expect(auth?["mode"] as? String == "token")
|
||||
#expect(auth?["token"] as? String == "test-token") // pragma: allowlist secret
|
||||
#expect((root?["meta"] as? [String: Any]) != nil)
|
||||
|
||||
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
|
||||
let last = rawAudit.split(whereSeparator: \.isNewline).map(String.init).last
|
||||
let auditRoot = try JSONSerialization.jsonObject(with: Data((last ?? "{}").utf8)) as? [String: Any]
|
||||
#expect(auditRoot?["result"] as? String == "success")
|
||||
#expect(auditRoot?["preservedGatewayAuth"] as? Bool == true)
|
||||
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
|
||||
#expect(suspicious.contains("gateway-auth-preserved"))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `save dict rejects gateway mode removal and keeps previous config`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "local",
|
||||
"auth": [
|
||||
"mode": "token",
|
||||
"token": "test-token", // pragma: allowlist secret
|
||||
],
|
||||
],
|
||||
"browser": [
|
||||
"enabled": true,
|
||||
],
|
||||
])
|
||||
let before = try String(contentsOf: configPath, encoding: .utf8)
|
||||
|
||||
let saved = OpenClawConfigFile.saveDict([
|
||||
"browser": [
|
||||
"enabled": false,
|
||||
],
|
||||
])
|
||||
|
||||
#expect(!saved)
|
||||
let after = try String(contentsOf: configPath, encoding: .utf8)
|
||||
#expect(after == before)
|
||||
|
||||
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
|
||||
let lines = rawAudit.split(whereSeparator: \.isNewline).map(String.init)
|
||||
guard let last = lines.last else {
|
||||
Issue.record("Missing rejected config audit line")
|
||||
return
|
||||
}
|
||||
let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any]
|
||||
#expect(auditRoot?["result"] as? String == "rejected")
|
||||
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
|
||||
let blocking = auditRoot?["blocking"] as? [String] ?? []
|
||||
#expect(suspicious.contains("gateway-mode-removed"))
|
||||
#expect(blocking.contains("gateway-mode-removed"))
|
||||
if let rejectedPath = auditRoot?["rejectedPath"] as? String {
|
||||
#expect(FileManager().fileExists(atPath: rejectedPath))
|
||||
let attributes = try FileManager().attributesOfItem(atPath: rejectedPath)
|
||||
let mode = attributes[.posixPermissions] as? NSNumber
|
||||
#expect(mode?.intValue == 0o600)
|
||||
} else {
|
||||
Issue.record("Missing rejected payload path")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import CoreLocation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct PermissionManagerLocationTests {
|
||||
@Test
|
||||
func `authorizedAlways counts for both modes`() {
|
||||
#expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: false))
|
||||
#expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: true))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `other statuses not authorized`() {
|
||||
#expect(!PermissionManager.isLocationAuthorized(status: .notDetermined, requireAlways: false))
|
||||
#expect(!PermissionManager.isLocationAuthorized(status: .denied, requireAlways: false))
|
||||
#expect(!PermissionManager.isLocationAuthorized(status: .restricted, requireAlways: false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import CoreLocation
|
||||
import OpenClawIPC
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct PermissionManagerTests {
|
||||
@Test func `voice wake permission helpers match status`() async {
|
||||
let direct = PermissionManager.voiceWakePermissionsGranted()
|
||||
let ensured = await PermissionManager.ensureVoiceWakePermissions(interactive: false)
|
||||
#expect(ensured == direct)
|
||||
}
|
||||
|
||||
@Test func `status can query non interactive caps`() async {
|
||||
let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording]
|
||||
let status = await PermissionManager.status(caps)
|
||||
#expect(status.keys.count == caps.count)
|
||||
}
|
||||
|
||||
@Test func `ensure non interactive does not throw`() async {
|
||||
let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording]
|
||||
let ensured = await PermissionManager.ensure(caps, interactive: false)
|
||||
#expect(ensured.keys.count == caps.count)
|
||||
}
|
||||
|
||||
@Test func `location status matches authorization always`() async {
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
let results = await PermissionManager.status([.location])
|
||||
#expect(results[.location] == (status == .authorizedAlways))
|
||||
}
|
||||
|
||||
@Test func `ensure location non interactive matches authorization always`() async {
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
let ensured = await PermissionManager.ensure([.location], interactive: false)
|
||||
#expect(ensured[.location] == (status == .authorizedAlways))
|
||||
}
|
||||
}
|
||||
7
apps/macos/Tests/OpenClawIPCTests/Placeholder.swift
Normal file
7
apps/macos/Tests/OpenClawIPCTests/Placeholder.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Testing
|
||||
|
||||
struct PlaceholderTests {
|
||||
@Test func placeholder() {
|
||||
#expect(true)
|
||||
}
|
||||
}
|
||||
131
apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift
Normal file
131
apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
struct RemotePortTunnelTests {
|
||||
@Test func `tunnel owns its SSH process instead of multiplexing`() {
|
||||
let options = RemotePortTunnel._testSSHOptions(localPort: 28789, remotePort: 18789)
|
||||
|
||||
#expect(options.contains("ControlMaster=no"))
|
||||
#expect(options.contains("ControlPath=none"))
|
||||
#expect(options.contains("ControlPersist=no"))
|
||||
#expect(options.contains("ForkAfterAuthentication=no"))
|
||||
#expect(options.contains("28789:127.0.0.1:18789"))
|
||||
#expect(options.contains("StrictHostKeyChecking=yes"))
|
||||
#expect(options.contains("UpdateHostKeys=yes"))
|
||||
}
|
||||
|
||||
@Test func `tunnel requires explicit opt in to use SSH config host key policy`() {
|
||||
let options = RemotePortTunnel._testSSHOptions(
|
||||
localPort: 28789,
|
||||
remotePort: 18789,
|
||||
hostKeyPolicy: .openssh)
|
||||
|
||||
#expect(!options.contains { $0.hasPrefix("StrictHostKeyChecking=") })
|
||||
#expect(!options.contains { $0.hasPrefix("UpdateHostKeys=") })
|
||||
}
|
||||
|
||||
@Test func `drain stderr does not crash when handle closed`() {
|
||||
let pipe = Pipe()
|
||||
let handle = pipe.fileHandleForReading
|
||||
try? handle.close()
|
||||
|
||||
let drained = RemotePortTunnel._testDrainStderr(handle)
|
||||
#expect(drained.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `port is free detects I pv4 listener`() {
|
||||
var fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
#expect(fd >= 0)
|
||||
guard fd >= 0 else { return }
|
||||
defer {
|
||||
if fd >= 0 { _ = Darwin.close(fd) }
|
||||
}
|
||||
|
||||
var one: Int32 = 1
|
||||
_ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one)))
|
||||
|
||||
var addr = sockaddr_in()
|
||||
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
addr.sin_family = sa_family_t(AF_INET)
|
||||
addr.sin_port = 0
|
||||
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
||||
|
||||
let bound = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
Darwin.bind(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
#expect(bound == 0)
|
||||
guard bound == 0 else { return }
|
||||
#expect(Darwin.listen(fd, 1) == 0)
|
||||
|
||||
var name = sockaddr_in()
|
||||
var nameLen = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let got = withUnsafeMutablePointer(to: &name) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
getsockname(fd, sa, &nameLen)
|
||||
}
|
||||
}
|
||||
#expect(got == 0)
|
||||
guard got == 0 else { return }
|
||||
|
||||
let port = UInt16(bigEndian: name.sin_port)
|
||||
#expect(RemotePortTunnel._testPortIsFree(port) == false)
|
||||
|
||||
_ = Darwin.close(fd)
|
||||
fd = -1
|
||||
|
||||
// In parallel test runs, another test may briefly grab the same ephemeral port.
|
||||
// Poll for a short window to avoid flakiness.
|
||||
let deadline = Date().addingTimeInterval(0.5)
|
||||
var free = false
|
||||
while Date() < deadline {
|
||||
if RemotePortTunnel._testPortIsFree(port) {
|
||||
free = true
|
||||
break
|
||||
}
|
||||
usleep(10000) // 10ms
|
||||
}
|
||||
#expect(free == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func `remote port override prefers explicit remote port`() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configPath]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://127.0.0.1:19089",
|
||||
"remotePort": 18789,
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
#expect(RemotePortTunnel._testResolveRemotePortOverride(
|
||||
defaultRemotePort: 19089,
|
||||
sshHost: "gateway.example") == 18789)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func `remote port override can read loopback url port`() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configPath]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://127.0.0.1:18789",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
#expect(RemotePortTunnel._testResolveRemotePortOverride(
|
||||
defaultRemotePort: 19089,
|
||||
sshHost: "gateway.example") == 18789)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,38 @@
|
||||
import Testing
|
||||
@testable import OpenClawMacCLI
|
||||
|
||||
struct RootCommandParserTests {
|
||||
@Test func `parse root command returns nil for empty args`() {
|
||||
#expect(parseRootCommand([]) == nil)
|
||||
}
|
||||
|
||||
@Test func `parse root command splits command name and args`() throws {
|
||||
let command = try #require(parseRootCommand(["connect", "--json", "--timeout", "3000"]))
|
||||
|
||||
#expect(command.name == "connect")
|
||||
#expect(command.args == ["--json", "--timeout", "3000"])
|
||||
}
|
||||
|
||||
@Test func `help aliases resolve to usage`() {
|
||||
for args in [[], ["-h"], ["--help"], ["help"]] {
|
||||
#expect(resolveRootCommandAction(args) == .usage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `known commands preserve trailing args`() {
|
||||
#expect(resolveRootCommandAction(["connect", "--json"]) == .connect(["--json"]))
|
||||
#expect(
|
||||
resolveRootCommandAction(["configure-remote", "--ssh-target", "alice@example.com"])
|
||||
== .configureRemote(["--ssh-target", "alice@example.com"]))
|
||||
#expect(resolveRootCommandAction(["discover", "--include-local"]) == .discover(["--include-local"]))
|
||||
#expect(resolveRootCommandAction(["wizard", "--mode", "local"]) == .wizard(["--mode", "local"]))
|
||||
}
|
||||
|
||||
@Test func `unknown command resolves to nonzero exit action`() {
|
||||
#expect(resolveRootCommandAction(["nope"]) == .unknown(exitCode: 1))
|
||||
}
|
||||
|
||||
@Test func `command names remain case sensitive`() {
|
||||
#expect(resolveRootCommandAction(["Connect"]) == .unknown(exitCode: 1))
|
||||
}
|
||||
}
|
||||
125
apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift
Normal file
125
apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct RuntimeLocatorTests {
|
||||
private func makeTempExecutable(contents: String) throws -> URL {
|
||||
let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let path = dir.appendingPathComponent("node")
|
||||
try contents.write(to: path, atomically: true, encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
||||
return path
|
||||
}
|
||||
|
||||
@Test func `resolve succeeds with valid node`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo v22.19.0
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
guard case let .success(res) = result else {
|
||||
Issue.record("Expected success, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(res.path == node.path)
|
||||
#expect(res.version == RuntimeVersion(major: 22, minor: 19, patch: 0))
|
||||
}
|
||||
|
||||
@Test func `resolve fails on boundary below minimum`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo v22.18.9
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
guard case let .failure(.unsupported(_, found, path, _)) = result else {
|
||||
Issue.record("Expected unsupported error, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(found == RuntimeVersion(major: 22, minor: 18, patch: 9))
|
||||
#expect(path == node.path)
|
||||
}
|
||||
|
||||
@Test func `resolve rejects early node 23`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo v23.7.0
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
guard case let .failure(.unsupported(_, found, path, _)) = result else {
|
||||
Issue.record("Expected unsupported error, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(found == RuntimeVersion(major: 23, minor: 7, patch: 0))
|
||||
#expect(path == node.path)
|
||||
}
|
||||
|
||||
@Test func `resolve accepts node 23 with statement columns`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo v23.11.0
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
guard case let .success(res) = result else {
|
||||
Issue.record("Expected success, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(res.path == node.path)
|
||||
#expect(res.version == RuntimeVersion(major: 23, minor: 11, patch: 0))
|
||||
}
|
||||
|
||||
@Test func `resolve fails when too old`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo v18.2.0
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
guard case let .failure(.unsupported(_, found, path, _)) = result else {
|
||||
Issue.record("Expected unsupported error, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(found == RuntimeVersion(major: 18, minor: 2, patch: 0))
|
||||
#expect(path == node.path)
|
||||
}
|
||||
|
||||
@Test func `resolve fails when version unparsable`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo node-version:unknown
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
guard case let .failure(.versionParse(_, raw, path, _)) = result else {
|
||||
Issue.record("Expected versionParse error, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(raw.contains("unknown"))
|
||||
#expect(path == node.path)
|
||||
}
|
||||
|
||||
@Test func `describe failure includes paths`() {
|
||||
let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"]))
|
||||
#expect(msg.contains("Node >=22.19.0 <23 or >=23.11.0"))
|
||||
#expect(msg.contains("PATH searched: /tmp/a:/tmp/b"))
|
||||
|
||||
let parseMsg = RuntimeLocator.describeFailure(
|
||||
.versionParse(
|
||||
kind: .node,
|
||||
raw: "garbage",
|
||||
path: "/usr/local/bin/node",
|
||||
searchPaths: ["/usr/local/bin"]))
|
||||
#expect(parseMsg.contains("Node >=22.19.0 <23 or >=23.11.0"))
|
||||
}
|
||||
|
||||
@Test func `runtime version parses with leading V and metadata`() {
|
||||
#expect(RuntimeVersion.from(string: "v22.1.3") == RuntimeVersion(major: 22, minor: 1, patch: 3))
|
||||
#expect(RuntimeVersion.from(string: "node 22.3.0-alpha.1") == RuntimeVersion(major: 22, minor: 3, patch: 0))
|
||||
#expect(RuntimeVersion.from(string: "bogus") == nil)
|
||||
}
|
||||
}
|
||||
20
apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift
Normal file
20
apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct ScreenshotSizeTests {
|
||||
@Test
|
||||
func `read PNG size returns dimensions`() throws {
|
||||
let pngBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+WZxkAAAAASUVORK5CYII="
|
||||
let data = try #require(Data(base64Encoded: pngBase64))
|
||||
let size = ScreenshotSize.readPNGSize(data: data)
|
||||
#expect(size?.width == 1)
|
||||
#expect(size?.height == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `read PNG size rejects non PNG data`() {
|
||||
#expect(ScreenshotSize.readPNGSize(data: Data("nope".utf8)) == nil)
|
||||
}
|
||||
}
|
||||
21
apps/macos/Tests/OpenClawIPCTests/SemverTests.swift
Normal file
21
apps/macos/Tests/OpenClawIPCTests/SemverTests.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct SemverTests {
|
||||
@Test func `comparison orders by major minor patch`() {
|
||||
let a = Semver(major: 1, minor: 0, patch: 0)
|
||||
let b = Semver(major: 1, minor: 1, patch: 0)
|
||||
let c = Semver(major: 1, minor: 1, patch: 1)
|
||||
let d = Semver(major: 2, minor: 0, patch: 0)
|
||||
|
||||
#expect(a < b)
|
||||
#expect(b < c)
|
||||
#expect(c < d)
|
||||
#expect(d > a)
|
||||
}
|
||||
|
||||
@Test func `description matches parts`() {
|
||||
let v = Semver(major: 3, minor: 2, patch: 1)
|
||||
#expect(v.description == "3.2.1")
|
||||
}
|
||||
}
|
||||
49
apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift
Normal file
49
apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct SessionDataTests {
|
||||
@Test func `session kind from key detects common kinds`() {
|
||||
#expect(SessionKind.from(key: "global") == .global)
|
||||
#expect(SessionKind.from(key: "cron:daily") == .cron)
|
||||
#expect(SessionKind.from(key: "agent:main:cron:daily") == .cron)
|
||||
#expect(SessionKind.from(key: "discord:group:engineering") == .group)
|
||||
#expect(SessionKind.from(key: "unknown") == .unknown)
|
||||
#expect(SessionKind.from(key: "user@example.com") == .direct)
|
||||
}
|
||||
|
||||
@Test func `session token stats format K tokens rounds as expected`() {
|
||||
#expect(SessionTokenStats.formatKTokens(999) == "999")
|
||||
#expect(SessionTokenStats.formatKTokens(1000) == "1.0k")
|
||||
#expect(SessionTokenStats.formatKTokens(12340) == "12k")
|
||||
}
|
||||
|
||||
@Test func `session token stats percent used clamps to100`() {
|
||||
let stats = SessionTokenStats(input: 0, output: 0, total: 250_000, contextTokens: 200_000)
|
||||
#expect(stats.percentUsed == 100)
|
||||
}
|
||||
|
||||
@Test func `session row flag labels include non default flags`() {
|
||||
let row = SessionRow(
|
||||
id: "x",
|
||||
key: "user@example.com",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
provider: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date(),
|
||||
sessionId: nil,
|
||||
thinkingLevel: "high",
|
||||
verboseLevel: "debug",
|
||||
systemSent: true,
|
||||
abortedLastRun: true,
|
||||
tokens: SessionTokenStats(input: 1, output: 2, total: 3, contextTokens: 10),
|
||||
model: nil)
|
||||
#expect(row.flagLabels.contains("think high"))
|
||||
#expect(row.flagLabels.contains("verbose debug"))
|
||||
#expect(row.flagLabels.contains("system sent"))
|
||||
#expect(row.flagLabels.contains("aborted"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct SessionMenuPreviewTests {
|
||||
@Test func `loader returns cached items`() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||
let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready)
|
||||
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
||||
|
||||
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(loaded.status == .ready)
|
||||
#expect(loaded.items.count == 1)
|
||||
#expect(loaded.items.first?.text == "Hi")
|
||||
}
|
||||
|
||||
@Test func `loader returns empty when cached empty`() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty)
|
||||
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
||||
|
||||
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(loaded.status == .empty)
|
||||
#expect(loaded.items.isEmpty)
|
||||
}
|
||||
}
|
||||
225
apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift
Normal file
225
apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift
Normal file
@@ -0,0 +1,225 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct SettingsViewSmokeTests {
|
||||
@Test func `cron settings builds body`() {
|
||||
let store = CronJobsStore(isPreview: true)
|
||||
store.schedulerEnabled = false
|
||||
store.schedulerStorePath = "/tmp/openclaw-cron-store.json"
|
||||
|
||||
let job1 = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: " Morning Check-in ",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "ping"),
|
||||
delivery: nil,
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_200_000,
|
||||
runningAtMs: nil,
|
||||
lastRunAtMs: 1_700_000_050_000,
|
||||
lastStatus: "ok",
|
||||
lastError: nil,
|
||||
lastDurationMs: 123))
|
||||
|
||||
let job2 = CronJob(
|
||||
id: "job-2",
|
||||
agentId: nil,
|
||||
name: "",
|
||||
description: nil,
|
||||
enabled: false,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .every(everyMs: 30000, anchorMs: nil),
|
||||
sessionTarget: .isolated,
|
||||
wakeMode: .nextHeartbeat,
|
||||
payload: .agentTurn(
|
||||
message: "hello",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 30,
|
||||
deliver: nil,
|
||||
channel: nil,
|
||||
to: nil,
|
||||
bestEffortDeliver: nil),
|
||||
delivery: CronDelivery(mode: .announce, channel: "sms", to: "+15551234567", bestEffort: true),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: nil,
|
||||
runningAtMs: nil,
|
||||
lastRunAtMs: nil,
|
||||
lastStatus: nil,
|
||||
lastError: nil,
|
||||
lastDurationMs: nil))
|
||||
|
||||
store.jobs = [job1, job2]
|
||||
store.selectedJobId = job1.id
|
||||
store.runEntries = [
|
||||
CronRunLogEntry(
|
||||
ts: 1_700_000_050_000,
|
||||
jobId: job1.id,
|
||||
action: "finished",
|
||||
status: "ok",
|
||||
error: nil,
|
||||
summary: "ok",
|
||||
runAtMs: 1_700_000_050_000,
|
||||
durationMs: 123,
|
||||
nextRunAtMs: 1_700_000_200_000),
|
||||
]
|
||||
|
||||
let view = CronSettings(store: store)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `cron settings renders in hosting view`() {
|
||||
let store = CronJobsStore(isPreview: true)
|
||||
store.schedulerEnabled = false
|
||||
store.jobs = [
|
||||
CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Morning Check-in",
|
||||
description: "Summary job",
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||
sessionTarget: .isolated,
|
||||
wakeMode: .nextHeartbeat,
|
||||
payload: .agentTurn(
|
||||
message: "Summarize",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: nil,
|
||||
channel: nil,
|
||||
to: nil,
|
||||
bestEffortDeliver: nil),
|
||||
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_200_000,
|
||||
runningAtMs: nil,
|
||||
lastRunAtMs: 1_700_000_050_000,
|
||||
lastStatus: "ok",
|
||||
lastError: nil,
|
||||
lastDurationMs: 1200)),
|
||||
]
|
||||
store.selectedJobId = "job-1"
|
||||
store.runEntries = [
|
||||
CronRunLogEntry(
|
||||
ts: 1_700_000_050_000,
|
||||
jobId: "job-1",
|
||||
action: "finished",
|
||||
status: "ok",
|
||||
error: nil,
|
||||
summary: "done",
|
||||
runAtMs: 1_700_000_050_000,
|
||||
durationMs: 1200,
|
||||
nextRunAtMs: 1_700_000_200_000),
|
||||
]
|
||||
|
||||
let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
|
||||
let hosting = NSHostingView(rootView: view)
|
||||
hosting.frame = NSRect(x: 0, y: 0, width: 900, height: 700)
|
||||
hosting.layoutSubtreeIfNeeded()
|
||||
_ = hosting.fittingSize
|
||||
}
|
||||
|
||||
@Test func `cron settings exercises private views`() {
|
||||
CronSettings.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func `config settings builds body`() {
|
||||
let view = ConfigSettings()
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `debug settings builds body`() {
|
||||
let view = DebugSettings()
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `general settings builds body`() {
|
||||
let state = AppState(preview: true)
|
||||
let view = GeneralSettings(state: state)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `general settings exercises branches`() {
|
||||
GeneralSettings.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func `sessions settings builds body`() {
|
||||
let view = SessionsSettings(rows: SessionRow.previewRows, isPreview: true)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `instances settings builds body`() {
|
||||
let store = InstancesStore(isPreview: true)
|
||||
store.instances = [
|
||||
InstanceInfo(
|
||||
id: "local",
|
||||
host: "this-mac",
|
||||
ip: "127.0.0.1",
|
||||
version: "1.0",
|
||||
platform: "macos 15.0",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "MacPreview",
|
||||
lastInputSeconds: 12,
|
||||
mode: "local",
|
||||
reason: "test",
|
||||
text: "test instance",
|
||||
ts: Date().timeIntervalSince1970 * 1000),
|
||||
]
|
||||
let view = InstancesSettings(store: store)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `permissions settings builds body`() {
|
||||
let view = PermissionsSettings(
|
||||
status: [
|
||||
.notifications: true,
|
||||
.screenRecording: false,
|
||||
],
|
||||
refresh: {},
|
||||
showOnboarding: {})
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `settings root view builds body`() {
|
||||
let state = AppState(preview: true)
|
||||
let view = SettingsRootView(state: state, updater: nil, initialTab: .general)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `about settings builds body`() {
|
||||
let view = AboutSettings(updater: nil)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `voice wake settings builds body`() {
|
||||
let state = AppState(preview: true)
|
||||
let view = VoiceWakeSettings(state: state, isActive: false)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `skills settings builds body`() {
|
||||
let view = SkillsSettings(state: .preview)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `exec approvals settings builds body`() {
|
||||
let view = ExecApprovalsSettings()
|
||||
_ = view.body
|
||||
}
|
||||
}
|
||||
129
apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift
Normal file
129
apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
private func makeSkillStatus(
|
||||
name: String,
|
||||
description: String,
|
||||
source: String,
|
||||
filePath: String,
|
||||
skillKey: String,
|
||||
primaryEnv: String? = nil,
|
||||
emoji: String,
|
||||
homepage: String? = nil,
|
||||
disabled: Bool = false,
|
||||
eligible: Bool,
|
||||
requirements: SkillRequirements = SkillRequirements(bins: [], env: [], config: []),
|
||||
missing: SkillMissing = SkillMissing(bins: [], env: [], config: []),
|
||||
configChecks: [SkillStatusConfigCheck] = [],
|
||||
install: [SkillInstallOption] = [])
|
||||
-> SkillStatus
|
||||
{
|
||||
SkillStatus(
|
||||
name: name,
|
||||
description: description,
|
||||
source: source,
|
||||
filePath: filePath,
|
||||
baseDir: "/tmp/skills",
|
||||
skillKey: skillKey,
|
||||
primaryEnv: primaryEnv,
|
||||
emoji: emoji,
|
||||
homepage: homepage,
|
||||
always: false,
|
||||
disabled: disabled,
|
||||
eligible: eligible,
|
||||
requirements: requirements,
|
||||
missing: missing,
|
||||
configChecks: configChecks,
|
||||
install: install)
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct SkillsSettingsSmokeTests {
|
||||
@Test func `skills settings builds body with skills remote`() {
|
||||
let model = SkillsSettingsModel()
|
||||
model.statusMessage = "Loaded"
|
||||
model.skills = [
|
||||
makeSkillStatus(
|
||||
name: "Needs Setup",
|
||||
description: "Missing bins and env",
|
||||
source: "openclaw-managed",
|
||||
filePath: "/tmp/skills/needs-setup",
|
||||
skillKey: "needs-setup",
|
||||
primaryEnv: "API_KEY",
|
||||
emoji: "🧰",
|
||||
homepage: "https://example.com/needs-setup",
|
||||
eligible: false,
|
||||
requirements: SkillRequirements(
|
||||
bins: ["python3"],
|
||||
env: ["API_KEY"],
|
||||
config: ["skills.needs-setup"]),
|
||||
missing: SkillMissing(
|
||||
bins: ["python3"],
|
||||
env: ["API_KEY"],
|
||||
config: ["skills.needs-setup"]),
|
||||
configChecks: [
|
||||
SkillStatusConfigCheck(path: "skills.needs-setup", value: AnyCodable(false), satisfied: false),
|
||||
],
|
||||
install: [
|
||||
SkillInstallOption(id: "brew", kind: "brew", label: "brew install python", bins: ["python3"]),
|
||||
]),
|
||||
makeSkillStatus(
|
||||
name: "Ready Skill",
|
||||
description: "All set",
|
||||
source: "openclaw-bundled",
|
||||
filePath: "/tmp/skills/ready",
|
||||
skillKey: "ready",
|
||||
emoji: "✅",
|
||||
homepage: "https://example.com/ready",
|
||||
eligible: true,
|
||||
configChecks: [
|
||||
SkillStatusConfigCheck(path: "skills.ready", value: AnyCodable(true), satisfied: true),
|
||||
SkillStatusConfigCheck(path: "skills.limit", value: AnyCodable(5), satisfied: true),
|
||||
],
|
||||
install: []),
|
||||
makeSkillStatus(
|
||||
name: "Disabled Skill",
|
||||
description: "Disabled in config",
|
||||
source: "openclaw-extra",
|
||||
filePath: "/tmp/skills/disabled",
|
||||
skillKey: "disabled",
|
||||
emoji: "🚫",
|
||||
disabled: true,
|
||||
eligible: false),
|
||||
]
|
||||
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .remote
|
||||
var view = SkillsSettings(state: state, model: model)
|
||||
view.setFilterForTesting("all")
|
||||
_ = view.body
|
||||
view.setFilterForTesting("needsSetup")
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `skills settings builds body with local mode`() {
|
||||
let model = SkillsSettingsModel()
|
||||
model.skills = [
|
||||
makeSkillStatus(
|
||||
name: "Local Skill",
|
||||
description: "Local ready",
|
||||
source: "openclaw-workspace",
|
||||
filePath: "/tmp/skills/local",
|
||||
skillKey: "local",
|
||||
emoji: "🏠",
|
||||
eligible: true),
|
||||
]
|
||||
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .local
|
||||
var view = SkillsSettings(state: state, model: model)
|
||||
view.setFilterForTesting("ready")
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `skills settings exercises private views`() {
|
||||
SkillsSettings.exerciseForTesting()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct TailscaleIntegrationSectionTests {
|
||||
@Test func `tailscale section builds body when not installed`() {
|
||||
let service = TailscaleService(isInstalled: false, isRunning: false, statusError: "not installed")
|
||||
var view = TailscaleIntegrationSection(connectionMode: .local, isPaused: false)
|
||||
view.setTestingService(service)
|
||||
view.setTestingState(mode: "off", requireCredentials: false, statusMessage: "Idle")
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `tailscale section builds body for serve mode`() {
|
||||
let service = TailscaleService(
|
||||
isInstalled: true,
|
||||
isRunning: true,
|
||||
tailscaleHostname: "openclaw.tailnet.ts.net",
|
||||
tailscaleIP: "100.64.0.1")
|
||||
var view = TailscaleIntegrationSection(connectionMode: .local, isPaused: false)
|
||||
view.setTestingService(service)
|
||||
view.setTestingState(
|
||||
mode: "serve",
|
||||
requireCredentials: true,
|
||||
password: "secret",
|
||||
statusMessage: "Running")
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `tailscale section builds body for funnel mode`() {
|
||||
let service = TailscaleService(
|
||||
isInstalled: true,
|
||||
isRunning: false,
|
||||
tailscaleHostname: nil,
|
||||
tailscaleIP: nil,
|
||||
statusError: "not running")
|
||||
var view = TailscaleIntegrationSection(connectionMode: .remote, isPaused: false)
|
||||
view.setTestingService(service)
|
||||
view.setTestingState(
|
||||
mode: "funnel",
|
||||
requireCredentials: false,
|
||||
statusMessage: "Needs start",
|
||||
validationMessage: "Invalid token")
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `general tailscale hydration does not rewrite existing config`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
|
||||
let initialConfig = """
|
||||
{
|
||||
"meta": {
|
||||
"lastTouchedVersion": "2026.3.28",
|
||||
"lastTouchedAt": "2026-03-31T13:15:24.532Z"
|
||||
},
|
||||
"wizard": {
|
||||
"lastRunAt": "2026-03-30T14:24:54.570Z",
|
||||
"lastRunVersion": "2026.3.24"
|
||||
},
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"port": 18789,
|
||||
"bind": "auto",
|
||||
"tailscale": {
|
||||
"mode": "serve"
|
||||
},
|
||||
"auth": {
|
||||
"mode": "token",
|
||||
"token": "existing-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
try initialConfig.write(to: configPath, atomically: true, encoding: .utf8)
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
let before = try Data(contentsOf: configPath)
|
||||
let root = try #require(
|
||||
JSONSerialization.jsonObject(with: before) as? [String: Any])
|
||||
|
||||
await TailscaleIntegrationSection.simulateHydrationApplyForTesting(
|
||||
root: root,
|
||||
connectionMode: .local,
|
||||
isPaused: true,
|
||||
saveRoot: { root in
|
||||
OpenClawConfigFile.saveDict(root, allowGatewayAuthMutation: true)
|
||||
})
|
||||
|
||||
let after = try Data(contentsOf: configPath)
|
||||
#expect(after == before)
|
||||
|
||||
let afterRoot = try #require(
|
||||
JSONSerialization.jsonObject(with: after) as? [String: Any])
|
||||
let gateway = try #require(afterRoot["gateway"] as? [String: Any])
|
||||
let auth = try #require(gateway["auth"] as? [String: Any])
|
||||
let meta = try #require(afterRoot["meta"] as? [String: Any])
|
||||
let wizard = try #require(afterRoot["wizard"] as? [String: Any])
|
||||
|
||||
#expect(gateway["bind"] as? String == "auto")
|
||||
#expect(auth["mode"] as? String == "token")
|
||||
#expect(auth["token"] as? String == "existing-token") // pragma: allowlist secret
|
||||
#expect(meta["lastTouchedAt"] as? String == "2026-03-31T13:15:24.532Z")
|
||||
#expect(wizard["lastRunAt"] as? String == "2026-03-30T14:24:54.570Z")
|
||||
#expect(wizard["lastRunVersion"] as? String == "2026.3.24")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `unchanged tailscale apply clears stale messages`() {
|
||||
let messages = TailscaleIntegrationSection.messagesForTesting(
|
||||
didApply: false,
|
||||
success: true,
|
||||
connectionMode: .local,
|
||||
isPaused: false)
|
||||
|
||||
#expect(messages.statusMessage == nil)
|
||||
#expect(messages.validationMessage == nil)
|
||||
#expect(messages.shouldRecordSuccess == false)
|
||||
#expect(messages.shouldRestartGateway == false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawDiscovery
|
||||
|
||||
struct TailscaleServeGatewayDiscoveryTests {
|
||||
@Test func `discovers serve gateway from tailnet peers`() async {
|
||||
let statusJson = """
|
||||
{
|
||||
"Self": {
|
||||
"DNSName": "local-mac.tailnet-example.ts.net.",
|
||||
"HostName": "local-mac",
|
||||
"Online": true
|
||||
},
|
||||
"Peer": {
|
||||
"peer-1": {
|
||||
"DNSName": "gateway-host.tailnet-example.ts.net.",
|
||||
"HostName": "gateway-host",
|
||||
"Online": true
|
||||
},
|
||||
"peer-2": {
|
||||
"DNSName": "offline.tailnet-example.ts.net.",
|
||||
"HostName": "offline-box",
|
||||
"Online": false
|
||||
},
|
||||
"peer-3": {
|
||||
"DNSName": "local-mac.tailnet-example.ts.net.",
|
||||
"HostName": "local-mac",
|
||||
"Online": true
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let context = TailscaleServeGatewayDiscovery.DiscoveryContext(
|
||||
tailscaleStatus: { statusJson },
|
||||
probeHost: { host, _ in
|
||||
host == "gateway-host.tailnet-example.ts.net"
|
||||
})
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.0, context: context)
|
||||
#expect(beacons.count == 1)
|
||||
#expect(beacons.first?.displayName == "gateway-host")
|
||||
#expect(beacons.first?.tailnetDns == "gateway-host.tailnet-example.ts.net")
|
||||
#expect(beacons.first?.host == "gateway-host.tailnet-example.ts.net")
|
||||
#expect(beacons.first?.port == 443)
|
||||
}
|
||||
|
||||
@Test func `returns empty when status unavailable`() async {
|
||||
let context = TailscaleServeGatewayDiscovery.DiscoveryContext(
|
||||
tailscaleStatus: { nil },
|
||||
probeHost: { _, _ in true })
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.0, context: context)
|
||||
#expect(beacons.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolves bare executable from PATH`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let executable = tempDir.appendingPathComponent("tailscale")
|
||||
try "#!/bin/sh\necho ok\n".write(to: executable, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
|
||||
let env: [String: String] = ["PATH": tempDir.path]
|
||||
let resolved = TailscaleServeGatewayDiscovery.resolveExecutablePath("tailscale", env: env)
|
||||
#expect(resolved == executable.path)
|
||||
}
|
||||
|
||||
@Test func `rejects missing executable candidate`() {
|
||||
#expect(TailscaleServeGatewayDiscovery.resolveExecutablePath("", env: [:]) == nil)
|
||||
#expect(TailscaleServeGatewayDiscovery
|
||||
.resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil)
|
||||
}
|
||||
|
||||
@Test func `adds TERM for GUI-launched tailscale subprocesses`() {
|
||||
let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [
|
||||
"HOME": "/Users/tester",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
])
|
||||
|
||||
#expect(env["TERM"] == "dumb")
|
||||
#expect(env["HOME"] == "/Users/tester")
|
||||
#expect(env["PATH"] == "/usr/bin:/bin")
|
||||
}
|
||||
|
||||
@Test func `preserves existing TERM when building tailscale subprocess environment`() {
|
||||
let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [
|
||||
"TERM": "xterm-256color",
|
||||
"HOME": "/Users/tester",
|
||||
])
|
||||
|
||||
#expect(env["TERM"] == "xterm-256color")
|
||||
#expect(env["HOME"] == "/Users/tester")
|
||||
}
|
||||
}
|
||||
97
apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift
Normal file
97
apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct TalkAudioPlayerTests {
|
||||
@MainActor
|
||||
@Test func `play does not hang when playback ends or fails`() async throws {
|
||||
let wav = makeWav16Mono(sampleRate: 8000, samples: 80)
|
||||
defer { _ = TalkAudioPlayer.shared.stop() }
|
||||
|
||||
_ = try await withTimeout(seconds: 10.0) {
|
||||
await TalkAudioPlayer.shared.play(data: wav)
|
||||
}
|
||||
|
||||
#expect(true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func `play does not hang when play is called twice`() async throws {
|
||||
let wav = makeWav16Mono(sampleRate: 8000, samples: 800)
|
||||
defer { _ = TalkAudioPlayer.shared.stop() }
|
||||
|
||||
let first = Task { @MainActor in
|
||||
await TalkAudioPlayer.shared.play(data: wav)
|
||||
}
|
||||
|
||||
await Task.yield()
|
||||
_ = await TalkAudioPlayer.shared.play(data: wav)
|
||||
|
||||
_ = try await withTimeout(seconds: 10.0) {
|
||||
await first.value
|
||||
}
|
||||
#expect(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimeoutError: Error {}
|
||||
|
||||
private func withTimeout<T: Sendable>(
|
||||
seconds: Double,
|
||||
_ work: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
try await work()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw TimeoutError()
|
||||
}
|
||||
let result = try await group.next()
|
||||
group.cancelAll()
|
||||
guard let result else { throw TimeoutError() }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
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]) // RIFF
|
||||
data.appendLEUInt32(36 + dataSize)
|
||||
data.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) // WAVE
|
||||
|
||||
data.append(contentsOf: [0x66, 0x6D, 0x74, 0x20]) // fmt
|
||||
data.appendLEUInt32(16) // PCM
|
||||
data.appendLEUInt16(1) // audioFormat
|
||||
data.appendLEUInt16(channels)
|
||||
data.appendLEUInt32(sampleRate)
|
||||
data.appendLEUInt32(byteRate)
|
||||
data.appendLEUInt16(blockAlign)
|
||||
data.appendLEUInt16(bitsPerSample)
|
||||
|
||||
data.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) // data
|
||||
data.appendLEUInt32(dataSize)
|
||||
|
||||
// Silence samples.
|
||||
data.append(Data(repeating: 0, count: Int(dataSize)))
|
||||
return data
|
||||
}
|
||||
|
||||
extension Data {
|
||||
fileprivate mutating func appendLEUInt16(_ value: UInt16) {
|
||||
var v = value.littleEndian
|
||||
Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
fileprivate mutating func appendLEUInt32(_ value: UInt32) {
|
||||
var v = value.littleEndian
|
||||
Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct TalkModeConfigParsingTests {
|
||||
@Test func `rejects normalized talk provider payload without resolved`() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"provider": AnyCodable("elevenlabs"),
|
||||
"providers": AnyCodable([
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
]),
|
||||
"voiceId": AnyCodable("voice-legacy"),
|
||||
]
|
||||
|
||||
let selection = TalkModeRuntime.selectTalkProviderConfig(talk)
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func `falls back to legacy talk fields when normalized payload missing`() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"voiceId": AnyCodable("voice-legacy"),
|
||||
"apiKey": AnyCodable("legacy-key"),
|
||||
]
|
||||
|
||||
let selection = TalkModeRuntime.selectTalkProviderConfig(talk)
|
||||
#expect(selection?.provider == "elevenlabs")
|
||||
#expect(selection?.normalizedPayload == false)
|
||||
#expect(selection?.config["voiceId"]?.stringValue == "voice-legacy")
|
||||
#expect(selection?.config["apiKey"]?.stringValue == "legacy-key")
|
||||
}
|
||||
|
||||
@Test func `reads configured silence timeout ms`() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"silenceTimeoutMs": AnyCodable(1500),
|
||||
]
|
||||
|
||||
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == 1500)
|
||||
}
|
||||
|
||||
@Test func `defaults silence timeout ms when missing`() {
|
||||
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(nil) == TalkDefaults.silenceTimeoutMs)
|
||||
}
|
||||
|
||||
@Test func `defaults silence timeout ms when invalid`() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"silenceTimeoutMs": AnyCodable(0),
|
||||
]
|
||||
|
||||
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == TalkDefaults.silenceTimeoutMs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct TalkModeGatewayConfigTests {
|
||||
@Test func `mlx provider does not inherit elevenlabs defaults`() {
|
||||
let snapshot = ConfigSnapshot(
|
||||
path: nil,
|
||||
exists: true,
|
||||
raw: nil,
|
||||
hash: nil,
|
||||
parsed: nil,
|
||||
valid: true,
|
||||
config: [
|
||||
"talk": AnyCodable([
|
||||
"provider": "mlx",
|
||||
"providers": [
|
||||
"mlx": [
|
||||
"voiceId": "unused-voice",
|
||||
],
|
||||
],
|
||||
"resolved": [
|
||||
"provider": "mlx",
|
||||
"config": [
|
||||
"voiceId": "unused-voice",
|
||||
],
|
||||
],
|
||||
"speechLocale": "ru-RU",
|
||||
]),
|
||||
],
|
||||
issues: nil)
|
||||
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
snapshot: snapshot,
|
||||
defaultProvider: "elevenlabs",
|
||||
defaultModelIdFallback: "eleven_v3",
|
||||
defaultSilenceTimeoutMs: TalkDefaults.silenceTimeoutMs,
|
||||
envVoice: "env-voice",
|
||||
sagVoice: "sag-voice",
|
||||
envApiKey: "env-key")
|
||||
|
||||
#expect(parsed.activeProvider == "mlx")
|
||||
#expect(parsed.modelId == nil)
|
||||
#expect(parsed.apiKey == nil)
|
||||
#expect(parsed.voiceId == "unused-voice")
|
||||
#expect(parsed.speechLocaleID == "ru-RU")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import OpenClawKit
|
||||
import Speech
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct TalkModeRuntimeSpeechTests {
|
||||
@Test func `speech request uses dictation defaults`() {
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
|
||||
TalkModeRuntime.configureRecognitionRequest(request)
|
||||
|
||||
#expect(request.shouldReportPartialResults)
|
||||
#expect(request.taskHint == .dictation)
|
||||
}
|
||||
|
||||
@Test func `playback plan routes unsupported local providers through gateway speak`() {
|
||||
let elevenLabsPlan = TalkModeRuntime.playbackPlan(
|
||||
provider: "elevenlabs",
|
||||
apiKey: "key",
|
||||
voiceId: "voice")
|
||||
let missingKeyPlan = TalkModeRuntime.playbackPlan(
|
||||
provider: "elevenlabs",
|
||||
apiKey: nil,
|
||||
voiceId: "voice")
|
||||
let missingVoicePlan = TalkModeRuntime.playbackPlan(
|
||||
provider: "elevenlabs",
|
||||
apiKey: "key",
|
||||
voiceId: nil)
|
||||
let blankKeyPlan = TalkModeRuntime.playbackPlan(
|
||||
provider: "elevenlabs",
|
||||
apiKey: "",
|
||||
voiceId: "voice")
|
||||
let openAIPlan = TalkModeRuntime.playbackPlan(provider: "openai", apiKey: nil, voiceId: "onyx")
|
||||
let customPlan = TalkModeRuntime.playbackPlan(provider: "acme-speech", apiKey: nil, voiceId: nil)
|
||||
let mlxPlan = TalkModeRuntime.playbackPlan(provider: "mlx", apiKey: nil, voiceId: nil)
|
||||
let systemPlan = TalkModeRuntime.playbackPlan(provider: "system", apiKey: nil, voiceId: nil)
|
||||
|
||||
#expect(elevenLabsPlan == .elevenLabsThenSystemVoice(apiKey: "key", voiceId: "voice"))
|
||||
#expect(missingKeyPlan == .systemVoiceOnly)
|
||||
#expect(missingVoicePlan == .systemVoiceOnly)
|
||||
#expect(blankKeyPlan == .systemVoiceOnly)
|
||||
#expect(openAIPlan == .gatewayTalkSpeakThenSystemVoice)
|
||||
#expect(customPlan == .gatewayTalkSpeakThenSystemVoice)
|
||||
#expect(mlxPlan == .mlxThenSystemVoice)
|
||||
#expect(systemPlan == .systemVoiceOnly)
|
||||
}
|
||||
|
||||
@Test func `talk speak params carry resolved voice and directive overrides`() {
|
||||
let params = TalkModeRuntime.makeTalkSpeakParams(
|
||||
text: "hello",
|
||||
voiceId: "voice-123",
|
||||
modelId: "eleven_v3",
|
||||
outputFormat: "mp3_44100_128",
|
||||
directive: TalkDirective(
|
||||
modelId: "eleven_turbo_v2_5",
|
||||
speed: 1.1,
|
||||
rateWPM: 180,
|
||||
stability: 0.4,
|
||||
similarity: 0.7,
|
||||
style: 0.2,
|
||||
speakerBoost: true,
|
||||
seed: 42,
|
||||
normalize: "auto",
|
||||
language: "en",
|
||||
outputFormat: "mp3_44100_128",
|
||||
latencyTier: 3))
|
||||
|
||||
#expect(params["text"]?.value as? String == "hello")
|
||||
#expect(params["voiceId"]?.value as? String == "voice-123")
|
||||
#expect(params["modelId"]?.value as? String == "eleven_turbo_v2_5")
|
||||
#expect(params["outputFormat"]?.value as? String == "mp3_44100_128")
|
||||
#expect(params["speed"]?.value as? Double == 1.1)
|
||||
#expect(params["rateWpm"]?.value as? Int == 180)
|
||||
#expect(params["stability"]?.value as? Double == 0.4)
|
||||
#expect(params["similarity"]?.value as? Double == 0.7)
|
||||
#expect(params["style"]?.value as? Double == 0.2)
|
||||
#expect(params["speakerBoost"]?.value as? Bool == true)
|
||||
#expect(params["seed"]?.value as? Int == 42)
|
||||
#expect(params["normalize"]?.value as? String == "auto")
|
||||
#expect(params["language"]?.value as? String == "en")
|
||||
#expect(params["latencyTier"]?.value as? Int == 3)
|
||||
}
|
||||
}
|
||||
16
apps/macos/Tests/OpenClawIPCTests/TestFSHelpers.swift
Normal file
16
apps/macos/Tests/OpenClawIPCTests/TestFSHelpers.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
func makeTempDirForTests() throws -> URL {
|
||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
func makeExecutableForTests(at path: URL) throws {
|
||||
try FileManager().createDirectory(
|
||||
at: path.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
||||
}
|
||||
112
apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift
Normal file
112
apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
|
||||
actor TestIsolationLock {
|
||||
static let shared = TestIsolationLock()
|
||||
|
||||
private var locked = false
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func acquire() async {
|
||||
if !self.locked {
|
||||
self.locked = true
|
||||
return
|
||||
}
|
||||
await withCheckedContinuation { cont in
|
||||
self.waiters.append(cont)
|
||||
}
|
||||
// `unlock()` resumed us; lock is now held for this caller.
|
||||
}
|
||||
|
||||
func release() {
|
||||
if self.waiters.isEmpty {
|
||||
self.locked = false
|
||||
return
|
||||
}
|
||||
let next = self.waiters.removeFirst()
|
||||
next.resume()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum TestIsolation {
|
||||
static func withIsolatedState<T>(
|
||||
env: [String: String?] = [:],
|
||||
defaults: [String: Any?] = [:],
|
||||
_ body: () async throws -> T) async rethrows -> T
|
||||
{
|
||||
func restoreUserDefaults(_ values: [String: Any?], userDefaults: UserDefaults) {
|
||||
for (key, value) in values {
|
||||
if let value {
|
||||
userDefaults.set(value, forKey: key)
|
||||
} else {
|
||||
userDefaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restoreEnv(_ values: [String: String?]) {
|
||||
for (key, value) in values {
|
||||
if let value {
|
||||
setenv(key, value, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await TestIsolationLock.shared.acquire()
|
||||
var previousEnv: [String: String?] = [:]
|
||||
for (key, value) in env {
|
||||
previousEnv[key] = getenv(key).map { String(cString: $0) }
|
||||
if let value {
|
||||
setenv(key, value, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
let userDefaults = UserDefaults.standard
|
||||
var previousDefaults: [String: Any?] = [:]
|
||||
for (key, value) in defaults {
|
||||
previousDefaults[key] = userDefaults.object(forKey: key)
|
||||
if let value {
|
||||
userDefaults.set(value, forKey: key)
|
||||
} else {
|
||||
userDefaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await body()
|
||||
restoreUserDefaults(previousDefaults, userDefaults: userDefaults)
|
||||
restoreEnv(previousEnv)
|
||||
await TestIsolationLock.shared.release()
|
||||
return result
|
||||
} catch {
|
||||
restoreUserDefaults(previousDefaults, userDefaults: userDefaults)
|
||||
restoreEnv(previousEnv)
|
||||
await TestIsolationLock.shared.release()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static func withEnvValues<T>(
|
||||
_ values: [String: String?],
|
||||
_ body: () async throws -> T) async rethrows -> T
|
||||
{
|
||||
try await self.withIsolatedState(env: values, defaults: [:], body)
|
||||
}
|
||||
|
||||
static func withUserDefaultsValues<T>(
|
||||
_ values: [String: Any?],
|
||||
_ body: () async throws -> T) async rethrows -> T
|
||||
{
|
||||
try await self.withIsolatedState(env: [:], defaults: values, body)
|
||||
}
|
||||
|
||||
nonisolated static func tempConfigPath() -> String {
|
||||
FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-test-config-\(UUID().uuidString).json")
|
||||
.path
|
||||
}
|
||||
}
|
||||
83
apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift
Normal file
83
apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct UtilitiesTests {
|
||||
@Test func `age strings cover common windows`() {
|
||||
let now = Date(timeIntervalSince1970: 1_000_000)
|
||||
#expect(age(from: now, now: now) == "just now")
|
||||
#expect(age(from: now.addingTimeInterval(-45), now: now) == "just now")
|
||||
#expect(age(from: now.addingTimeInterval(-75), now: now) == "1 minute ago")
|
||||
#expect(age(from: now.addingTimeInterval(-10 * 60), now: now) == "10m ago")
|
||||
#expect(age(from: now.addingTimeInterval(-3600), now: now) == "1 hour ago")
|
||||
#expect(age(from: now.addingTimeInterval(-5 * 3600), now: now) == "5h ago")
|
||||
#expect(age(from: now.addingTimeInterval(-26 * 3600), now: now) == "yesterday")
|
||||
#expect(age(from: now.addingTimeInterval(-3 * 86400), now: now) == "3d ago")
|
||||
}
|
||||
|
||||
@Test func `parse SSH target supports user port and defaults`() {
|
||||
let parsed1 = CommandResolver.parseSSHTarget("alice@example.com:2222")
|
||||
#expect(parsed1?.user == "alice")
|
||||
#expect(parsed1?.host == "example.com")
|
||||
#expect(parsed1?.port == 2222)
|
||||
|
||||
let parsed2 = CommandResolver.parseSSHTarget("example.com")
|
||||
#expect(parsed2?.user == nil)
|
||||
#expect(parsed2?.host == "example.com")
|
||||
#expect(parsed2?.port == 22)
|
||||
|
||||
let parsed3 = CommandResolver.parseSSHTarget("bob@host")
|
||||
#expect(parsed3?.user == "bob")
|
||||
#expect(parsed3?.host == "host")
|
||||
#expect(parsed3?.port == 22)
|
||||
}
|
||||
|
||||
@Test func `sanitized target strips leading SSH prefix`() throws {
|
||||
let defaults = try #require(UserDefaults(suiteName: "UtilitiesTests.\(UUID().uuidString)"))
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
|
||||
|
||||
let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:])
|
||||
#expect(settings.mode == .remote)
|
||||
#expect(settings.target == "alice@example.com")
|
||||
}
|
||||
|
||||
@Test func `gateway entrypoint prefers dist over bin`() throws {
|
||||
let tmp = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let dist = tmp.appendingPathComponent("dist/index.js")
|
||||
let bin = tmp.appendingPathComponent("bin/openclaw.js")
|
||||
try FileManager().createDirectory(at: dist.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try FileManager().createDirectory(at: bin.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
FileManager().createFile(atPath: dist.path, contents: Data())
|
||||
FileManager().createFile(atPath: bin.path, contents: Data())
|
||||
|
||||
let entry = CommandResolver.gatewayEntrypoint(in: tmp)
|
||||
#expect(entry == dist.path)
|
||||
}
|
||||
|
||||
@Test func `log locator picks newest log file`() throws {
|
||||
let fm = FileManager()
|
||||
let dir = URL(fileURLWithPath: "/tmp/openclaw", isDirectory: true)
|
||||
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
let older = dir.appendingPathComponent("openclaw-old-\(UUID().uuidString).log")
|
||||
let newer = dir.appendingPathComponent("openclaw-new-\(UUID().uuidString).log")
|
||||
fm.createFile(atPath: older.path, contents: Data("old".utf8))
|
||||
fm.createFile(atPath: newer.path, contents: Data("new".utf8))
|
||||
try fm.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -100)], ofItemAtPath: older.path)
|
||||
try fm.setAttributes([.modificationDate: Date()], ofItemAtPath: newer.path)
|
||||
|
||||
let best = LogLocator.bestLogFile()
|
||||
#expect(best?.lastPathComponent == newer.lastPathComponent)
|
||||
|
||||
try? fm.removeItem(at: older)
|
||||
try? fm.removeItem(at: newer)
|
||||
}
|
||||
|
||||
@Test func `gateway entrypoint nil when missing`() {
|
||||
let tmp = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
#expect(CommandResolver.gatewayEntrypoint(in: tmp) == nil)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user