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

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

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

View File

@@ -0,0 +1,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)
}
}

View 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))
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View 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)
}
}

View File

@@ -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)
}
}

View 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)")
}
}
}

View File

@@ -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)
}
}

View 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)")
}
}
}

View File

@@ -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)
}
}

View File

@@ -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 youve 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)
}
}

View 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")
}
}

View 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)
}
}
}

View File

@@ -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"))
}
}
}
}

View File

@@ -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)
}
}

View 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)
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View 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")
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View 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)
}
}

View File

@@ -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")
}
}

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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?
}
}

View File

@@ -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)")
}
}
}
}

View File

@@ -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"))
}
}

View File

@@ -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)
}
}

View File

@@ -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: []),
])
}
}

View File

@@ -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"])
}
}

View File

@@ -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
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -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)")
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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)
}
}

View 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)
}
}

View File

@@ -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"))
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View 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")
}
}

View File

@@ -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"])
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -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()
}
}

View File

@@ -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
}())
}
}

View 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"))
}
}
}

View File

@@ -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))
}
}

View 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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"])
}
}
}

View File

@@ -0,0 +1,10 @@
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct OnboardingCoverageTests {
@Test func `exercise onboarding pages`() {
OnboardingView.exerciseForTesting()
}
}

View File

@@ -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)
}
}

View 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)
}
}

View 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")
}
}
}
}

View File

@@ -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))
}
}

View File

@@ -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))
}
}

View File

@@ -0,0 +1,7 @@
import Testing
struct PlaceholderTests {
@Test func placeholder() {
#expect(true)
}
}

View 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

View File

@@ -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))
}
}

View 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)
}
}

View 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)
}
}

View 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")
}
}

View 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"))
}
}

View File

@@ -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)
}
}

View 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
}
}

View 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()
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View 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) }
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View 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)
}

View 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
}
}

View 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