Add ez-assistant and kerberos service folders

This commit is contained in:
kelin
2026-02-11 14:56:03 -05:00
parent e4e8ae1b87
commit 9ccfb36923
4471 changed files with 746463 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import MoltbotProtocol
import Foundation
import Testing
@testable import Moltbot
@Suite
@MainActor
struct AgentEventStoreTests {
@Test
func appendAndClear() {
let store = AgentEventStore()
#expect(store.events.isEmpty)
store.append(ControlAgentEvent(
runId: "run",
seq: 1,
stream: "test",
ts: 0,
data: [:] as [String: MoltbotProtocol.AnyCodable],
summary: nil))
#expect(store.events.count == 1)
store.clear()
#expect(store.events.isEmpty)
}
@Test
func trimsToMaxEvents() {
let store = AgentEventStore()
for i in 1...401 {
store.append(ControlAgentEvent(
runId: "run",
seq: i,
stream: "test",
ts: Double(i),
data: [:] as [String: MoltbotProtocol.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,123 @@
import Foundation
import Testing
@testable import Moltbot
@Suite
struct AgentWorkspaceTests {
@Test
func displayPathUsesTildeForHome() {
let home = FileManager().homeDirectoryForCurrentUser
#expect(AgentWorkspace.displayPath(for: home) == "~")
let inside = home.appendingPathComponent("Projects", isDirectory: true)
#expect(AgentWorkspace.displayPath(for: inside).hasPrefix("~/"))
}
@Test
func resolveWorkspaceURLExpandsTilde() {
let url = AgentWorkspace.resolveWorkspaceURL(from: "~/tmp")
#expect(url.path.hasSuffix("/tmp"))
}
@Test
func agentsURLAppendsFilename() {
let root = URL(fileURLWithPath: "/tmp/ws", isDirectory: true)
let url = AgentWorkspace.agentsURL(workspaceURL: root)
#expect(url.lastPathComponent == AgentWorkspace.agentsFilename)
}
@Test
func bootstrapCreatesAgentsFileWhenMissing() throws {
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-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 bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-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)
switch result {
case .unsafe:
break
case .safe:
#expect(Bool(false), "Expected unsafe bootstrap safety result.")
}
}
@Test
func bootstrapSafetyAllowsExistingAgentsFile() throws {
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-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)
switch result {
case .safe:
break
case .unsafe:
#expect(Bool(false), "Expected safe bootstrap safety result.")
}
}
@Test
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-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 needsBootstrapFalseWhenIdentityAlreadySet() throws {
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-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,29 @@
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct AnthropicAuthControlsSmokeTests {
@Test func anthropicAuthControlsBuildsBodyLocal() {
let pkce = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
let view = AnthropicAuthControls(
connectionMode: .local,
oauthStatus: .connected(expiresAtMs: 1_700_000_000_000),
pkce: pkce,
code: "code#state",
statusText: "Detected code",
autoDetectClipboard: false,
autoConnectClipboard: false)
_ = view.body
}
@Test func anthropicAuthControlsBuildsBodyRemote() {
let view = AnthropicAuthControls(
connectionMode: .remote,
oauthStatus: .missingFile,
pkce: nil,
code: "",
statusText: nil)
_ = view.body
}
}

View File

@@ -0,0 +1,52 @@
import Foundation
import Testing
@testable import Moltbot
@Suite
struct AnthropicAuthResolverTests {
@Test
func prefersOAuthFileOverEnv() throws {
let dir = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-oauth-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let oauthFile = dir.appendingPathComponent("oauth.json")
let payload = [
"anthropic": [
"type": "oauth",
"refresh": "r1",
"access": "a1",
"expires": 1_234_567_890,
],
]
let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
try data.write(to: oauthFile, options: [.atomic])
let status = MoltbotOAuthStore.anthropicOAuthStatus(at: oauthFile)
let mode = AnthropicAuthResolver.resolve(environment: [
"ANTHROPIC_API_KEY": "sk-ant-ignored",
], oauthStatus: status)
#expect(mode == .oauthFile)
}
@Test
func reportsOAuthEnvWhenPresent() {
let mode = AnthropicAuthResolver.resolve(environment: [
"ANTHROPIC_OAUTH_TOKEN": "token",
], oauthStatus: .missingFile)
#expect(mode == .oauthEnv)
}
@Test
func reportsAPIKeyEnvWhenPresent() {
let mode = AnthropicAuthResolver.resolve(environment: [
"ANTHROPIC_API_KEY": "sk-ant-key",
], oauthStatus: .missingFile)
#expect(mode == .apiKeyEnv)
}
@Test
func reportsMissingWhenNothingConfigured() {
let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile)
#expect(mode == .missing)
}
}

View File

@@ -0,0 +1,31 @@
import Testing
@testable import Moltbot
@Suite
struct AnthropicOAuthCodeStateTests {
@Test
func parsesRawToken() {
let parsed = AnthropicOAuthCodeState.parse(from: "abcDEF1234#stateXYZ9876")
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
}
@Test
func parsesBacktickedToken() {
let parsed = AnthropicOAuthCodeState.parse(from: "`abcDEF1234#stateXYZ9876`")
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
}
@Test
func parsesCallbackURL() {
let raw = "https://console.anthropic.com/oauth/code/callback?code=abcDEF1234&state=stateXYZ9876"
let parsed = AnthropicOAuthCodeState.parse(from: raw)
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
}
@Test
func extractsFromSurroundingText() {
let raw = "Paste the code#state value: abcDEF1234#stateXYZ9876 then return."
let parsed = AnthropicOAuthCodeState.parse(from: raw)
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
}
}

View File

@@ -0,0 +1,38 @@
import MoltbotProtocol
import Foundation
import Testing
@testable import Moltbot
@Suite struct AnyCodableEncodingTests {
@Test func encodesSwiftArrayAndDictionaryValues() throws {
let payload: [String: Any] = [
"tags": ["node", "ios"],
"meta": ["count": 2],
"null": NSNull(),
]
let data = try JSONEncoder().encode(MoltbotProtocol.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 protocolAnyCodableEncodesPrimitiveArrays() throws {
let payload: [String: Any] = [
"items": [1, "two", NSNull(), ["ok": true]],
]
let data = try JSONEncoder().encode(MoltbotProtocol.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,34 @@
import Foundation
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct CLIInstallerTests {
@Test func installedLocationFindsExecutable() throws {
let fm = FileManager()
let root = fm.temporaryDirectory.appendingPathComponent(
"moltbot-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("moltbot")
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)
}
}

View File

@@ -0,0 +1,21 @@
import Testing
@testable import Moltbot
@Suite struct CameraCaptureServiceTests {
@Test func normalizeSnapDefaults() {
let res = CameraCaptureService.normalizeSnap(maxWidth: nil, quality: nil)
#expect(res.maxWidth == 1600)
#expect(res.quality == 0.9)
}
@Test func normalizeSnapClampsValues() {
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 MoltbotIPC
import Foundation
import Testing
@Suite struct CameraIPCTests {
@Test func cameraSnapCodableRoundtrip() 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 cameraClipCodableRoundtrip() 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 cameraClipDefaultsIncludeAudioToTrueWhenMissing() 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 Moltbot
@Suite(.serialized) struct CanvasFileWatcherTests {
private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent("moltbot-canvaswatch-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
@Test func detectsInPlaceFileWrites() 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 MoltbotIPC
import Foundation
import Testing
@Suite struct CanvasIPCTests {
@Test func canvasPresentCodableRoundtrip() 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 canvasPresentDecodesNilPlacementWhenMissing() 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,49 @@
import AppKit
import MoltbotIPC
import Foundation
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct CanvasWindowSmokeTests {
@Test func panelControllerShowsAndHides() async throws {
let root = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-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 windowControllerShowsAndCloses() async throws {
let root = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-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()
}
}

View File

@@ -0,0 +1,164 @@
import MoltbotProtocol
import SwiftUI
import Testing
@testable import Moltbot
private typealias SnapshotAnyCodable = Moltbot.AnyCodable
@Suite(.serialized)
@MainActor
struct ChannelsSettingsSmokeTests {
@Test func channelsSettingsBuildsBodyWithSnapshot() {
let store = ChannelsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
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": "moltbotbot"],
"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,
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
"imessage": "default",
])
store.whatsappLoginMessage = "Scan QR"
store.whatsappLoginQrDataUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ay7pS8AAAAASUVORK5CYII="
let view = ChannelsSettings(store: store)
_ = view.body
}
@Test func channelsSettingsBuildsBodyWithoutSnapshot() {
let store = ChannelsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
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,
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
"imessage": "default",
])
let view = ChannelsSettings(store: store)
_ = view.body
}
}

View File

@@ -0,0 +1,79 @@
import Foundation
import Testing
@testable import Moltbot
@Suite(.serialized)
struct MoltbotConfigFileTests {
@Test
func configPathRespectsEnvOverride() async {
let override = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-config-\(UUID().uuidString)")
.appendingPathComponent("moltbot.json")
.path
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
#expect(MoltbotConfigFile.url().path == override)
}
}
@MainActor
@Test
func remoteGatewayPortParsesAndMatchesHost() async {
let override = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-config-\(UUID().uuidString)")
.appendingPathComponent("moltbot.json")
.path
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
MoltbotConfigFile.saveDict([
"gateway": [
"remote": [
"url": "ws://gateway.ts.net:19999",
],
],
])
#expect(MoltbotConfigFile.remoteGatewayPort() == 19999)
#expect(MoltbotConfigFile.remoteGatewayPort(matchingHost: "gateway.ts.net") == 19999)
#expect(MoltbotConfigFile.remoteGatewayPort(matchingHost: "gateway") == 19999)
#expect(MoltbotConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil)
}
}
@MainActor
@Test
func setRemoteGatewayUrlPreservesScheme() async {
let override = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-config-\(UUID().uuidString)")
.appendingPathComponent("moltbot.json")
.path
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
MoltbotConfigFile.saveDict([
"gateway": [
"remote": [
"url": "wss://old-host:111",
],
],
])
MoltbotConfigFile.setRemoteGatewayUrl(host: "new-host", port: 2222)
let root = MoltbotConfigFile.loadDict()
let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String
#expect(url == "wss://new-host:2222")
}
}
@Test
func stateDirOverrideSetsConfigPath() async {
let dir = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-state-\(UUID().uuidString)", isDirectory: true)
.path
await TestIsolation.withEnvValues([
"CLAWDBOT_CONFIG_PATH": nil,
"CLAWDBOT_STATE_DIR": dir,
]) {
#expect(MoltbotConfigFile.stateDirURL().path == dir)
#expect(MoltbotConfigFile.url().path == "\(dir)/moltbot.json")
}
}
}

View File

@@ -0,0 +1,97 @@
import Foundation
import Testing
@testable import Moltbot
@Suite
struct MoltbotOAuthStoreTests {
@Test
func returnsMissingWhenFileAbsent() {
let url = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-oauth-\(UUID().uuidString)")
.appendingPathComponent("oauth.json")
#expect(MoltbotOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
}
@Test
func usesEnvOverrideForMoltbotOAuthDir() throws {
let key = "CLAWDBOT_OAUTH_DIR"
let previous = ProcessInfo.processInfo.environment[key]
defer {
if let previous {
setenv(key, previous, 1)
} else {
unsetenv(key)
}
}
let dir = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-oauth-\(UUID().uuidString)", isDirectory: true)
setenv(key, dir.path, 1)
#expect(MoltbotOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
}
@Test
func acceptsPiFormatTokens() throws {
let url = try self.writeOAuthFile([
"anthropic": [
"type": "oauth",
"refresh": "r1",
"access": "a1",
"expires": 1_234_567_890,
],
])
#expect(MoltbotOAuthStore.anthropicOAuthStatus(at: url).isConnected)
}
@Test
func acceptsTokenKeyVariants() throws {
let url = try self.writeOAuthFile([
"anthropic": [
"type": "oauth",
"refresh_token": "r1",
"access_token": "a1",
],
])
#expect(MoltbotOAuthStore.anthropicOAuthStatus(at: url).isConnected)
}
@Test
func reportsMissingProviderEntry() throws {
let url = try self.writeOAuthFile([
"other": [
"type": "oauth",
"refresh": "r1",
"access": "a1",
],
])
#expect(MoltbotOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
}
@Test
func reportsMissingTokens() throws {
let url = try self.writeOAuthFile([
"anthropic": [
"type": "oauth",
"refresh": "",
"access": "a1",
],
])
#expect(MoltbotOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
}
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
let dir = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-oauth-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("oauth.json")
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
try data.write(to: url, options: [.atomic])
return url
}
}

View File

@@ -0,0 +1,171 @@
import Darwin
import Foundation
import Testing
@testable import Moltbot
@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 makeTempDir() 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
}
private func makeExec(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)
}
@Test func prefersMoltbotBinary() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
let tmp = try makeTempDir()
CommandResolver.setProjectRoot(tmp.path)
let moltbotPath = tmp.appendingPathComponent("node_modules/.bin/moltbot")
try self.makeExec(at: moltbotPath)
let cmd = CommandResolver.moltbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:])
#expect(cmd.prefix(2).elementsEqual([moltbotPath.path, "gateway"]))
}
@Test func fallsBackToNodeAndScript() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
let tmp = try makeTempDir()
CommandResolver.setProjectRoot(tmp.path)
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
let scriptPath = tmp.appendingPathComponent("bin/moltbot.js")
try self.makeExec(at: nodePath)
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try self.makeExec(at: scriptPath)
let cmd = CommandResolver.moltbotCommand(
subcommand: "rpc",
defaults: defaults,
configRoot: [:],
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path])
#expect(cmd.count >= 3)
if cmd.count >= 3 {
#expect(cmd[0] == nodePath.path)
#expect(cmd[1] == scriptPath.path)
#expect(cmd[2] == "rpc")
}
}
@Test func fallsBackToPnpm() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
let tmp = try makeTempDir()
CommandResolver.setProjectRoot(tmp.path)
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
try self.makeExec(at: pnpmPath)
let cmd = CommandResolver.moltbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:])
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "moltbot", "rpc"]))
}
@Test func pnpmKeepsExtraArgsAfterSubcommand() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
let tmp = try makeTempDir()
CommandResolver.setProjectRoot(tmp.path)
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
try self.makeExec(at: pnpmPath)
let cmd = CommandResolver.moltbotCommand(
subcommand: "health",
extraArgs: ["--json", "--timeout", "5"],
defaults: defaults,
configRoot: [:])
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "moltbot", "health", "--json"]))
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
}
@Test func preferredPathsStartWithProjectNodeBins() async throws {
let tmp = try makeTempDir()
CommandResolver.setProjectRoot(tmp.path)
let first = CommandResolver.preferredPaths().first
#expect(first == tmp.appendingPathComponent("node_modules/.bin").path)
}
@Test func buildsSSHCommandForRemoteMode() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
defaults.set("clawd@example.com:2222", forKey: remoteTargetKey)
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
defaults.set("/srv/moltbot", forKey: remoteProjectRootKey)
let cmd = CommandResolver.moltbotCommand(
subcommand: "status",
extraArgs: ["--json"],
defaults: defaults,
configRoot: [:])
#expect(cmd.first == "/usr/bin/ssh")
if let marker = cmd.firstIndex(of: "--") {
#expect(cmd[marker + 1] == "clawd@example.com")
} else {
#expect(Bool(false))
}
#expect(cmd.contains("-i"))
#expect(cmd.contains("/tmp/id_ed25519"))
if let script = cmd.last {
#expect(script.contains("PRJ='/srv/moltbot'"))
#expect(script.contains("cd \"$PRJ\""))
#expect(script.contains("moltbot"))
#expect(script.contains("status"))
#expect(script.contains("--json"))
#expect(script.contains("CLI="))
}
}
@Test func rejectsUnsafeSSHTargets() async throws {
#expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil)
#expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil)
#expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222)
}
@Test func configRootLocalOverridesRemoteDefaults() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
defaults.set("clawd@example.com:2222", forKey: remoteTargetKey)
let tmp = try makeTempDir()
CommandResolver.setProjectRoot(tmp.path)
let moltbotPath = tmp.appendingPathComponent("node_modules/.bin/moltbot")
try self.makeExec(at: moltbotPath)
let cmd = CommandResolver.moltbotCommand(
subcommand: "daemon",
defaults: defaults,
configRoot: ["gateway": ["mode": "local"]])
#expect(cmd.first == moltbotPath.path)
#expect(cmd.count >= 2)
if cmd.count >= 2 {
#expect(cmd[1] == "daemon")
}
}
}

View File

@@ -0,0 +1,68 @@
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct ConfigStoreTests {
@Test func loadUsesRemoteInRemoteMode() 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 loadUsesLocalInLocalMode() 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 saveRoutesToRemoteInRemoteMode() 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 saveRoutesToLocalInLocalMode() 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)
}
}

View File

@@ -0,0 +1,24 @@
import Darwin
import Foundation
import Testing
@Suite(.serialized)
struct CoverageDumpTests {
@Test func periodicallyFlushCoverage() 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,37 @@
import AppKit
import Testing
@testable import Moltbot
@Suite
@MainActor
struct CritterIconRendererTests {
@Test func makeIconRendersExpectedSize() {
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 makeIconRendersWithBadge() {
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 critterStatusLabelExercisesHelpers() async {
await CritterStatusLabel.exerciseForTesting()
}
}

View File

@@ -0,0 +1,93 @@
import SwiftUI
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct CronJobEditorSmokeTests {
@Test func statusPillBuildsBody() {
_ = StatusPill(text: "ok", tint: .green).body
_ = StatusPill(text: "disabled", tint: .secondary).body
}
@Test func cronJobEditorBuildsBodyForNewJob() {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
_ = view.body
}
@Test func cronJobEditorBuildsBodyForExistingJob() {
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: true,
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
state: CronJobState(
nextRunAtMs: 1_700_000_100_000,
runningAtMs: nil,
lastRunAtMs: 1_700_000_050_000,
lastStatus: "ok",
lastError: nil,
lastDurationMs: 1000))
let view = CronJobEditor(
job: job,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
_ = view.body
}
@Test func cronJobEditorExercisesBuilders() {
let channelsStore = ChannelsStore(isPreview: true)
var view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
view.exerciseForTesting()
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
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,129 @@
import Foundation
import Testing
@testable import Moltbot
@Suite
struct CronModelsTests {
@Test func scheduleAtEncodesAndDecodes() throws {
let schedule = CronSchedule.at(atMs: 123)
let data = try JSONEncoder().encode(schedule)
let decoded = try JSONDecoder().decode(CronSchedule.self, from: data)
#expect(decoded == schedule)
}
@Test func scheduleEveryEncodesAndDecodesWithAnchor() 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 scheduleCronEncodesAndDecodesWithTimezone() 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 payloadAgentTurnEncodesAndDecodes() 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 jobEncodesAndDecodesDeleteAfterRun() throws {
let job = CronJob(
id: "job-1",
agentId: nil,
name: "One-shot",
description: nil,
enabled: true,
deleteAfterRun: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 1_700_000_000_000),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "ping"),
isolation: nil,
state: CronJobState())
let data = try JSONEncoder().encode(job)
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
#expect(decoded.deleteAfterRun == true)
}
@Test func scheduleDecodeRejectsUnknownKind() {
let json = """
{"kind":"wat","atMs":1}
"""
#expect(throws: DecodingError.self) {
_ = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8))
}
}
@Test func payloadDecodeRejectsUnknownKind() {
let json = """
{"kind":"wat","text":"hello"}
"""
#expect(throws: DecodingError.self) {
_ = try JSONDecoder().decode(CronPayload.self, from: Data(json.utf8))
}
}
@Test func displayNameTrimsWhitespaceAndFallsBack() {
let base = CronJob(
id: "x",
agentId: nil,
name: " hello ",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "hi"),
isolation: nil,
state: CronJobState())
#expect(base.displayName == "hello")
var unnamed = base
unnamed.name = " "
#expect(unnamed.displayName == "Untitled job")
}
@Test func nextRunDateAndLastRunDateDeriveFromState() {
let job = CronJob(
id: "x",
agentId: nil,
name: "t",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "hi"),
isolation: nil,
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))
}
}

View File

@@ -0,0 +1,41 @@
import Testing
@testable import Moltbot
@Suite
struct DeviceModelCatalogTests {
@Test
func symbolPrefersModelIdentifierPrefixes() {
#expect(DeviceModelCatalog
.symbol(deviceFamily: "iPad", modelIdentifier: "iPad16,6", friendlyName: nil) == "ipad")
#expect(DeviceModelCatalog
.symbol(deviceFamily: "iPhone", modelIdentifier: "iPhone17,3", friendlyName: nil) == "iphone")
}
@Test
func symbolUsesFriendlyNameForMacVariants() {
#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 symbolFallsBackToDeviceFamily() {
#expect(DeviceModelCatalog.symbol(deviceFamily: "Android", modelIdentifier: "", friendlyName: nil) == "android")
#expect(DeviceModelCatalog.symbol(deviceFamily: "Linux", modelIdentifier: "", friendlyName: nil) == "cpu")
}
@Test
func presentationUsesBundledModelMappings() {
let presentation = DeviceModelCatalog.presentation(deviceFamily: "iPhone", modelIdentifier: "iPhone1,1")
#expect(presentation?.title == "iPhone")
}
}

View File

@@ -0,0 +1,49 @@
import Foundation
import Testing
@testable import Moltbot
struct ExecAllowlistTests {
@Test func matchUsesResolvedPath() {
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func matchUsesBasenameForSimplePattern() {
let entry = ExecAllowlistEntry(pattern: "rg")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func matchIsCaseInsensitive() {
let entry = ExecAllowlistEntry(pattern: "RG")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func matchSupportsGlobStar() {
let entry = ExecAllowlistEntry(pattern: "/opt/**/rg")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
}

View File

@@ -0,0 +1,60 @@
import Foundation
import Testing
@testable import Moltbot
@Suite struct ExecApprovalHelpersTests {
@Test func parseDecisionTrimsAndRejectsInvalid() {
#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 allowlistPatternPrefersResolution() {
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 requiresAskMatchesPolicy() {
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,56 @@
import Testing
@testable import Moltbot
@Suite
@MainActor
struct ExecApprovalsGatewayPrompterTests {
@Test func sessionMatchPrefersActiveSession() {
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 sessionFallbackUsesRecentActivity() {
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 defaultBehaviorMatchesMode() {
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)
}
}

View File

@@ -0,0 +1,155 @@
import Foundation
import Testing
@Suite struct FileHandleLegacyAPIGuardTests {
@Test func sourcesAvoidLegacyNonThrowingFileHandleReadAPIs() throws {
let testFile = URL(fileURLWithPath: #filePath)
let packageRoot = testFile
.deletingLastPathComponent() // MoltbotIPCTests
.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 Moltbot
@Suite struct FileHandleSafeReadTests {
@Test func readToEndSafelyReturnsEmptyForClosedHandle() {
let pipe = Pipe()
let handle = pipe.fileHandleForReading
try? handle.close()
let data = handle.readToEndSafely()
#expect(data.isEmpty)
}
@Test func readSafelyUpToCountReturnsEmptyForClosedHandle() {
let pipe = Pipe()
let handle = pipe.fileHandleForReading
try? handle.close()
let data = handle.readSafely(upToCount: 16)
#expect(data.isEmpty)
}
@Test func readToEndSafelyReadsPipeContents() {
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 readSafelyUpToCountReadsIncrementally() {
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 Moltbot
@Suite struct GatewayAgentChannelTests {
@Test func shouldDeliverBlocksWebChat() {
#expect(GatewayAgentChannel.webchat.shouldDeliver(true) == false)
#expect(GatewayAgentChannel.webchat.shouldDeliver(false) == false)
}
@Test func shouldDeliverAllowsLastAndProviderChannels() {
#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.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
}
@Test func initRawNormalizesAndFallsBackToLast() {
#expect(GatewayAgentChannel(raw: nil) == .last)
#expect(GatewayAgentChannel(raw: " ") == .last)
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
#expect(GatewayAgentChannel(raw: "googlechat") == .googlechat)
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
#expect(GatewayAgentChannel(raw: "unknown") == .last)
}
}

View File

@@ -0,0 +1,24 @@
import Testing
@testable import Moltbot
@Suite(.serialized)
struct GatewayAutostartPolicyTests {
@Test func startsGatewayOnlyWhenLocalAndNotPaused() {
#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 ensuresLaunchAgentWhenLocalAndNotAttachOnly() {
#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,286 @@
import MoltbotKit
import Foundation
import os
import Testing
@testable import Moltbot
@Suite struct GatewayConnectionTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
private let helloDelayMs: Int
var state: URLSessionTask.State = .suspended
init(helloDelayMs: Int = 0) {
self.helloDelayMs = helloDelayMs
}
func snapshotCancelCount() -> Int { self.cancelCount.withLock { $0 } }
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
self.cancelCount.withLock { $0 += 1 }
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let currentSendCount = self.sendCount.withLock { count in
defer { count += 1 }
return count
}
// First send is the connect handshake request. Subsequent sends are request frames.
if currentSendCount == 0 {
guard case let .data(data) = message else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
(obj["method"] as? String) == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
return
}
guard case let .data(data) = message else { return }
guard
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
let id = obj["id"] as? String
else {
return
}
let response = Self.responseData(id: id)
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
}
func receive() async throws -> URLSessionWebSocketTask.Message {
if self.helloDelayMs > 0 {
try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000)
}
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
func emitIncoming(_ data: Data) {
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(data)))
}
private 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
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
private static func responseData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let makeCount = OSAllocatedUnfairLock(initialState: 0)
private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
private let helloDelayMs: Int
init(helloDelayMs: Int = 0) {
self.helloDelayMs = helloDelayMs
}
func snapshotMakeCount() -> Int { self.makeCount.withLock { $0 } }
func snapshotCancelCount() -> Int {
self.tasks.withLock { tasks in
tasks.reduce(0) { $0 + $1.snapshotCancelCount() }
}
}
func latestTask() -> FakeWebSocketTask? {
self.tasks.withLock { $0.last }
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
self.makeCount.withLock { $0 += 1 }
let task = FakeWebSocketTask(helloDelayMs: self.helloDelayMs)
self.tasks.withLock { $0.append(task) }
return WebSocketTaskBox(task: task)
}
}
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 requestReusesSingleWebSocketForSameConfig() async throws {
let session = FakeWebSocketSession()
let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(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 requestReconfiguresAndCancelsOnTokenChange() async throws {
let session = FakeWebSocketSession()
let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: "a")
let conn = GatewayConnection(
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(session: session))
_ = 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 concurrentRequestsStillUseSingleWebSocket() async throws {
let session = FakeWebSocketSession(helloDelayMs: 150)
let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(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 subscribeReplaysLatestSnapshot() async throws {
let session = FakeWebSocketSession()
let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(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 subscribeEmitsSeqGapBeforeEvent() async throws {
let session = FakeWebSocketSession()
let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(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()?.emitIncoming(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()?.emitIncoming(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,160 @@
import MoltbotKit
import Foundation
import os
import Testing
@testable import Moltbot
@Suite struct GatewayChannelConnectTests {
private enum FakeResponse {
case helloOk(delayMs: Int)
case invalid(delayMs: Int)
}
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let response: FakeResponse
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(
initialState: nil)
var state: URLSessionTask.State = .suspended
init(response: FakeResponse) {
self.response = response
}
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
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 }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let delayMs: Int
let msg: URLSessionWebSocketTask.Message
switch self.response {
case let .helloOk(ms):
delayMs = ms
let id = self.connectRequestID.withLock { $0 } ?? "connect"
msg = .data(Self.connectOkData(id: id))
case let .invalid(ms):
delayMs = ms
msg = .string("not json")
}
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
return msg
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
// The production channel sets up a continuous receive loop after hello.
// Tests only need the handshake receive; keep the loop idle.
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private 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
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let response: FakeResponse
private let makeCount = OSAllocatedUnfairLock(initialState: 0)
init(response: FakeResponse) {
self.response = response
}
func snapshotMakeCount() -> Int { self.makeCount.withLock { $0 } }
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
self.makeCount.withLock { $0 += 1 }
let task = FakeWebSocketTask(response: self.response)
return WebSocketTaskBox(task: task)
}
}
@Test func concurrentConnectIsSingleFlightOnSuccess() async throws {
let session = FakeWebSocketSession(response: .helloOk(delayMs: 200))
let channel = GatewayChannelActor(
url: 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 concurrentConnectSharesFailure() async {
let session = FakeWebSocketSession(response: .invalid(delayMs: 200))
let channel = GatewayChannelActor(
url: 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)
}
}

View File

@@ -0,0 +1,134 @@
import MoltbotKit
import Foundation
import os
import Testing
@testable import Moltbot
@Suite struct GatewayChannelRequestTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let requestSendDelayMs: Int
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
var state: URLSessionTask.State = .suspended
init(requestSendDelayMs: Int) {
self.requestSendDelayMs = requestSendDelayMs
}
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
_ = message
let currentSendCount = self.sendCount.withLock { count in
defer { count += 1 }
return count
}
// First send is the connect handshake. Second send is the request frame.
if currentSendCount == 0 {
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 }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
}
if currentSendCount == 1 {
try await Task.sleep(nanoseconds: UInt64(self.requestSendDelayMs) * 1_000_000)
throw URLError(.cannotConnectToHost)
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private 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
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let requestSendDelayMs: Int
init(requestSendDelayMs: Int) {
self.requestSendDelayMs = requestSendDelayMs
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
let task = FakeWebSocketTask(requestSendDelayMs: self.requestSendDelayMs)
return WebSocketTaskBox(task: task)
}
}
@Test func requestTimeoutThenSendFailureDoesNotDoubleResume() async {
let session = FakeWebSocketSession(requestSendDelayMs: 100)
let channel = GatewayChannelActor(
url: 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,129 @@
import MoltbotKit
import Foundation
import os
import Testing
@testable import Moltbot
@Suite struct GatewayChannelShutdownTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
var state: URLSessionTask.State = .suspended
func snapshotCancelCount() -> Int { self.cancelCount.withLock { $0 } }
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
self.cancelCount.withLock { $0 += 1 }
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
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 }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
func triggerReceiveFailure() {
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
}
private 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
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let makeCount = OSAllocatedUnfairLock(initialState: 0)
private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
func snapshotMakeCount() -> Int { self.makeCount.withLock { $0 } }
func latestTask() -> FakeWebSocketTask? { self.tasks.withLock { $0.last } }
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
self.makeCount.withLock { $0 += 1 }
let task = FakeWebSocketTask()
self.tasks.withLock { $0.append(task) }
return WebSocketTaskBox(task: task)
}
}
@Test func shutdownPreventsReconnectLoopFromReceiveFailure() async throws {
let session = FakeWebSocketSession()
let channel = GatewayChannelActor(
url: 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()?.triggerReceiveFailure()
// 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,59 @@
import MoltbotKit
import Foundation
import Testing
@testable import Moltbot
@testable import MoltbotIPC
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
var state: URLSessionTask.State = .running
func resume() {}
func cancel(with _: URLSessionWebSocketTask.CloseCode, reason _: Data?) {
self.state = .canceling
}
func send(_: URLSessionWebSocketTask.Message) async throws {}
func receive() async throws -> URLSessionWebSocketTask.Message {
throw URLError(.cannotConnectToHost)
}
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
completionHandler(.failure(URLError(.cannotConnectToHost)))
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
func makeWebSocketTask(url _: URL) -> WebSocketTaskBox {
WebSocketTaskBox(task: FakeWebSocketTask())
}
}
private func makeTestGatewayConnection() -> GatewayConnection {
GatewayConnection(
configProvider: {
(url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil)
},
sessionBox: WebSocketSessionBox(session: FakeWebSocketSession()))
}
@Suite(.serialized) struct GatewayConnectionControlTests {
@Test func statusFailsWhenProcessMissing() async {
let connection = makeTestGatewayConnection()
let result = await connection.status()
#expect(result.ok == false)
#expect(result.error != nil)
}
@Test func rejectEmptyMessage() async {
let connection = makeTestGatewayConnection()
let result = await connection.sendAgent(
message: "",
thinking: nil,
sessionKey: "main",
deliver: false,
to: nil)
#expect(result.ok == false)
}
}

View File

@@ -0,0 +1,124 @@
import MoltbotDiscovery
import Testing
@Suite
@MainActor
struct GatewayDiscoveryModelTests {
@Test func localGatewayMatchesLanHost() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: ["studio"],
displayTokens: [])
#expect(GatewayDiscoveryModel.isLocalGateway(
lanHost: "studio.local",
tailnetDns: nil,
displayName: nil,
serviceName: nil,
local: local))
}
@Test func localGatewayMatchesTailnetDns() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: ["studio"],
displayTokens: [])
#expect(GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: "studio.tailnet.example",
displayName: nil,
serviceName: nil,
local: local))
}
@Test func localGatewayMatchesDisplayName() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: [],
displayTokens: ["peter's mac studio"])
#expect(GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: nil,
displayName: "Peter's Mac Studio (Moltbot)",
serviceName: nil,
local: local))
}
@Test func remoteGatewayDoesNotMatch() {
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 localGatewayMatchesServiceName() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: ["studio"],
displayTokens: [])
#expect(GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: nil,
displayName: nil,
serviceName: "studio-gateway",
local: local))
}
@Test func serviceNameDoesNotFalsePositiveOnSubstringHostToken() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: ["steipete"],
displayTokens: [])
#expect(!GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: nil,
displayName: nil,
serviceName: "steipetacstudio (Moltbot)",
local: local))
#expect(GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: nil,
displayName: nil,
serviceName: "steipete (Moltbot)",
local: local))
}
@Test func parsesGatewayTXTFields() {
let parsed = GatewayDiscoveryModel.parseGatewayTXT([
"lanHost": " studio.local ",
"tailnetDns": " peters-mac-studio-1.ts.net ",
"sshPort": " 2222 ",
"gatewayPort": " 18799 ",
"cliPath": " /opt/moltbot ",
])
#expect(parsed.lanHost == "studio.local")
#expect(parsed.tailnetDns == "peters-mac-studio-1.ts.net")
#expect(parsed.sshPort == 2222)
#expect(parsed.gatewayPort == 18799)
#expect(parsed.cliPath == "/opt/moltbot")
}
@Test func parsesGatewayTXTDefaults() {
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.cliPath == nil)
}
@Test func buildsSSHTarget() {
#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")
}
}

View File

@@ -0,0 +1,184 @@
import Foundation
import Testing
@testable import Moltbot
@Suite struct GatewayEndpointStoreTests {
private func makeDefaults() -> UserDefaults {
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return defaults
}
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
stdoutPath: nil,
stderrPath: nil,
port: nil,
bind: nil,
token: "launchd-token",
password: nil)
let envToken = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: [:],
env: ["CLAWDBOT_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 resolveGatewayTokenIgnoresLaunchdInRemoteMode() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
stdoutPath: nil,
stderrPath: nil,
port: nil,
bind: nil,
token: "launchd-token",
password: nil)
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: true,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(token == nil)
}
@Test func resolveGatewayPasswordFallsBackToLaunchd() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
stdoutPath: nil,
stderrPath: nil,
port: nil,
bind: nil,
token: nil,
password: "launchd-pass")
let password = GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(password == "launchd-pass")
}
@Test func connectionModeResolverPrefersConfigModeOverDefaults() {
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 connectionModeResolverTrimsConfigMode() {
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 connectionModeResolverFallsBackToDefaultsWhenMissingConfig() {
let defaults = self.makeDefaults()
defaults.set("remote", forKey: connectionModeKey)
let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults)
#expect(resolved.mode == .remote)
}
@Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() {
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 connectionModeResolverPrefersRemoteURLWhenModeMissing() {
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 resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.1.2")
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.1.5")
#expect(host == "100.64.1.5")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "custom",
tailscaleIP: "100.64.1.9",
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
#expect(url?.port == 18789)
#expect(url?.absoluteString == "ws://gateway:18789")
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
import Testing
@testable import Moltbot
@Suite struct GatewayEnvironmentTests {
@Test func semverParsesCommonForms() {
#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)
}
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
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 gatewayPortDefaultsAndRespectsOverride() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(
env: ["CLAWDBOT_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 expectedGatewayVersionFromStringUsesParser() {
#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 MoltbotProtocol
import Foundation
import Testing
@Suite struct GatewayFrameDecodeTests {
@Test func decodesEventFrameWithAnyCodablePayload() 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 decodesRequestFrameWithNestedParams() 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 decodesUnknownFrameAndPreservesRaw() 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,41 @@
import Foundation
import Testing
@testable import Moltbot
@Suite struct GatewayLaunchAgentManagerTests {
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
let url = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["moltbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
"EnvironmentVariables": [
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
"CLAWDBOT_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 launchAgentPlistSnapshotAllowsMissingBind() throws {
let url = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["moltbot", "gateway-daemon", "--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,147 @@
import MoltbotKit
import Foundation
import os
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct GatewayProcessManagerTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
var state: URLSessionTask.State = .suspended
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
self.cancelCount.withLock { $0 += 1 }
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let currentSendCount = self.sendCount.withLock { count in
defer { count += 1 }
return count
}
if currentSendCount == 0 {
guard case let .data(data) = message else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
(obj["method"] as? String) == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
return
}
guard case let .data(data) = message else { return }
guard
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
let id = obj["id"] as? String
else {
return
}
let response = Self.responseData(id: id)
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private 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
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
private static func responseData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
let task = FakeWebSocketTask()
self.tasks.withLock { $0.append(task) }
return WebSocketTaskBox(task: task)
}
}
@Test func clearsLastFailureWhenHealthSucceeds() async {
let session = FakeWebSocketSession()
let url = 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)
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
import Testing
@testable import Moltbot
@Suite 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 decodesCleanJSON() async throws {
let data = Data(sampleJSON.utf8)
let snap = decodeHealthSnapshot(from: data)
#expect(snap?.channels["whatsapp"]?.linked == true)
#expect(snap?.sessions.count == 1)
}
@Test func decodesWithLeadingNoise() async throws {
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 failsWithoutBraces() async throws {
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 Moltbot
@Suite struct HealthStoreStateTests {
@Test @MainActor func linkedChannelProbeFailureDegradesState() async throws {
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,26 @@
import AppKit
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct HoverHUDControllerTests {
@Test func hoverHUDControllerPresentsAndDismisses() 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 Moltbot
@Suite(.serialized)
@MainActor
struct InstancesSettingsSmokeTests {
@Test func instancesSettingsBuildsBodyWithMultipleInstances() {
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 instancesSettingsExercisesHelpers() {
InstancesSettings.exerciseForTesting()
}
}

View File

@@ -0,0 +1,36 @@
import MoltbotProtocol
import Testing
@testable import Moltbot
@Suite struct InstancesStoreTests {
@Test
@MainActor
func presenceEventPayloadDecodesViaJSONEncoder() {
// Build a payload that mirrors the gateway's presence event shape:
// { "presence": [ PresenceEntry ] }
let entry: [String: MoltbotProtocol.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: MoltbotProtocol.AnyCodable] = [
"presence": .init([MoltbotProtocol.AnyCodable(entry)]),
]
let payload = MoltbotProtocol.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,24 @@
import Darwin
import Foundation
import Testing
@testable import Moltbot
@Suite struct LogLocatorTests {
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
let fm = FileManager()
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let logDir = baseDir.appendingPathComponent("moltbot-tests-\(UUID().uuidString)")
setenv("CLAWDBOT_LOG_DIR", logDir.path, 1)
defer {
unsetenv("CLAWDBOT_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,215 @@
import AppKit
import MoltbotProtocol
import Foundation
import Testing
@testable import Moltbot
@Suite(.serialized)
struct LowCoverageHelperTests {
private typealias ProtoAnyCodable = MoltbotProtocol.AnyCodable
@Test func anyCodableHelperAccessors() 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 attributedStringStripsForegroundColor() {
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 viewMetricsReduceWidth() {
let value = ViewMetricsTesting.reduceWidth(current: 120, next: 180)
#expect(value == 180)
}
@Test func shellExecutorHandlesEmptyCommand() async {
let result = await ShellExecutor.runDetailed(command: [], cwd: nil, env: nil, timeout: nil)
#expect(result.success == false)
#expect(result.errorMessage != nil)
}
@Test func shellExecutorRunsCommand() 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 shellExecutorTimesOut() async {
let result = await ShellExecutor.runDetailed(command: ["/bin/sleep", "1"], cwd: nil, env: nil, timeout: 0.05)
#expect(result.timedOut == true)
}
@Test func shellExecutorDrainsStdoutAndStderr() 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 nodeInfoCodableRoundTrip() 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 presenceReporterHelpers() {
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 portGuardianParsesListenersAndBuildsReports() {
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 @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() 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 = 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 menuContextCardInjectorInsertsAndFindsIndex() {
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 canvasWindowHelperFunctions() {
#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 = URL(string: "http://192.168.1.2")!
#expect(CanvasWindowController._testIsLocalNetworkCanvasURL(url))
#expect(CanvasWindowController._testParseIPv4("not-an-ip") == nil)
}
}

View File

@@ -0,0 +1,99 @@
import AppKit
import MoltbotProtocol
import SwiftUI
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct LowCoverageViewSmokeTests {
@Test func contextMenuCardBuildsBody() {
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
}
@Test func settingsToggleRowBuildsBody() {
var flag = false
let binding = Binding(get: { flag }, set: { flag = $0 })
let view = SettingsToggleRow(title: "Enable", subtitle: "Detail", binding: binding)
_ = view.body
}
@Test func voiceWakeTestCardBuildsBodyAcrossStates() {
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 agentEventsWindowBuildsBodyWithEvent() {
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 notifyOverlayPresentsAndDismisses() 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 visualEffectViewHostsInNSHostingView() {
let hosting = NSHostingView(rootView: VisualEffectView(material: .sidebar))
_ = hosting.fittingSize
hosting.rootView = VisualEffectView(material: .popover, emphasized: true)
_ = hosting.fittingSize
}
@Test func menuHostedItemHostsContent() {
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 dockIconManagerUpdatesVisibility() {
_ = NSApplication.shared
UserDefaults.standard.set(false, forKey: showDockIconKey)
DockIconManager.shared.updateDockVisibility()
DockIconManager.shared.temporarilyShowDock()
}
@Test func voiceWakeSettingsExercisesHelpers() {
VoiceWakeSettings.exerciseForTesting()
}
@Test func debugSettingsExercisesHelpers() async {
await DebugSettings.exerciseForTesting()
}
}

View File

@@ -0,0 +1,99 @@
import MoltbotChatUI
import MoltbotProtocol
import Testing
@testable import Moltbot
@Suite struct MacGatewayChatTransportMappingTests {
@Test func snapshotMapsToHealth() {
let snapshot = Snapshot(
presence: [],
health: MoltbotProtocol.AnyCodable(["ok": MoltbotProtocol.AnyCodable(false)]),
stateversion: StateVersion(presence: 1, health: 1),
uptimems: 123,
configpath: nil,
statedir: nil,
sessiondefaults: nil)
let hello = HelloOk(
type: "hello",
_protocol: 2,
server: [:],
features: [:],
snapshot: snapshot,
canvashosturl: nil,
auth: nil,
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 healthEventMapsToHealth() {
let frame = EventFrame(
type: "event",
event: "health",
payload: MoltbotProtocol.AnyCodable(["ok": MoltbotProtocol.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 tickEventMapsToTick() {
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 chatEventMapsToChat() {
let payload = MoltbotProtocol.AnyCodable([
"runId": MoltbotProtocol.AnyCodable("run-1"),
"sessionKey": MoltbotProtocol.AnyCodable("main"),
"state": MoltbotProtocol.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 unknownEventMapsToNil() {
let frame = EventFrame(
type: "event",
event: "unknown",
payload: MoltbotProtocol.AnyCodable(["a": MoltbotProtocol.AnyCodable(1)]),
seq: 1,
stateversion: nil)
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame))
#expect(mapped == nil)
}
@Test func seqGapMapsToSeqGap() {
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.seqGap(expected: 1, received: 9))
#expect({
if case .seqGap = mapped { return true }
return false
}())
}
}

View File

@@ -0,0 +1,97 @@
import MoltbotKit
import CoreLocation
import Foundation
import Testing
@testable import Moltbot
struct MacNodeRuntimeTests {
@Test func handleInvokeRejectsUnknownCommand() async {
let runtime = MacNodeRuntime()
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-1", command: "unknown.command"))
#expect(response.ok == false)
}
@Test func handleInvokeRejectsEmptySystemRun() async throws {
let runtime = MacNodeRuntime()
let params = MoltbotSystemRunParams(command: [])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2", command: MoltbotSystemCommand.run.rawValue, paramsJSON: json))
#expect(response.ok == false)
}
@Test func handleInvokeRejectsEmptySystemWhich() async throws {
let runtime = MacNodeRuntime()
let params = MoltbotSystemWhichParams(bins: [])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2b", command: MoltbotSystemCommand.which.rawValue, paramsJSON: json))
#expect(response.ok == false)
}
@Test func handleInvokeRejectsEmptyNotification() async throws {
let runtime = MacNodeRuntime()
let params = MoltbotSystemNotifyParams(title: "", body: "")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-3", command: MoltbotSystemCommand.notify.rawValue, paramsJSON: json))
#expect(response.ok == false)
}
@Test func handleInvokeCameraListRequiresEnabledCamera() async {
await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) {
let runtime = MacNodeRuntime()
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-4", command: MoltbotCameraCommand.list.rawValue))
#expect(response.ok == false)
#expect(response.error?.message.contains("CAMERA_DISABLED") == true)
}
}
@Test func handleInvokeScreenRecordUsesInjectedServices() async throws {
@MainActor
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool)
{
let url = FileManager().temporaryDirectory
.appendingPathComponent("moltbot-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: MoltbotLocationAccuracy,
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)
}
}

View File

@@ -0,0 +1,78 @@
import MoltbotDiscovery
import SwiftUI
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct MasterDiscoveryMenuSmokeTests {
@Test func inlineListBuildsBodyWhenEmpty() {
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 inlineListBuildsBodyWithMasterAndSelection() {
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 menuBuildsBodyWithMasters() {
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,41 @@
import SwiftUI
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct MenuContentSmokeTests {
@Test func menuContentBuildsBodyLocalMode() {
let state = AppState(preview: true)
state.connectionMode = .local
let view = MenuContent(state: state, updater: nil)
_ = view.body
}
@Test func menuContentBuildsBodyRemoteMode() {
let state = AppState(preview: true)
state.connectionMode = .remote
let view = MenuContent(state: state, updater: nil)
_ = view.body
}
@Test func menuContentBuildsBodyUnconfiguredMode() {
let state = AppState(preview: true)
state.connectionMode = .unconfigured
let view = MenuContent(state: state, updater: nil)
_ = view.body
}
@Test func menuContentBuildsBodyWithDebugAndCanvas() {
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
}
}

View File

@@ -0,0 +1,96 @@
import AppKit
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct MenuSessionsInjectorTests {
@Test func injectsDisconnectedMessage() {
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)
#expect(menu.items.contains { $0.tag == 9_415_557 })
}
@Test func injectsSessionRows() {
let injector = MenuSessionsInjector()
injector.setTestingControlChannelConnected(true)
let defaults = SessionDefaults(model: "anthropic/claude-opus-4-5", 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-5"),
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-5"),
]
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-codex",
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: ""))
injector.injectForTesting(into: menu)
#expect(menu.items.contains { $0.tag == 9_415_557 })
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
}
}

View File

@@ -0,0 +1,53 @@
import Foundation
import Testing
@testable import Moltbot
@Suite
struct ModelCatalogLoaderTests {
@Test
func loadParsesModelsFromTypeScriptAndSorts() async throws {
let src = """
export const MODELS = {
openai: {
"gpt-4o-mini": { name: "GPT-4o mini", contextWindow: 128000 } satisfies any,
"gpt-4o": { name: "GPT-4o", contextWindow: 128000 } as any,
"gpt-3.5": { contextWindow: 16000 },
},
anthropic: {
"claude-3": { name: "Claude 3", contextWindow: 200000 },
},
};
"""
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("models-\(UUID().uuidString).ts")
defer { try? FileManager().removeItem(at: tmp) }
try src.write(to: tmp, atomically: true, encoding: .utf8)
let choices = try await ModelCatalogLoader.load(from: tmp.path)
#expect(choices.count == 4)
#expect(choices.first?.provider == "anthropic")
#expect(choices.first?.id == "claude-3")
let ids = Set(choices.map(\.id))
#expect(ids == Set(["claude-3", "gpt-4o", "gpt-4o-mini", "gpt-3.5"]))
let openai = choices.filter { $0.provider == "openai" }
let openaiNames = openai.map(\.name)
#expect(openaiNames == openaiNames.sorted { a, b in
a.localizedCaseInsensitiveCompare(b) == .orderedAscending
})
}
@Test
func loadWithNoExportReturnsEmptyChoices() async throws {
let src = "const NOPE = 1;"
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("models-\(UUID().uuidString).ts")
defer { try? FileManager().removeItem(at: tmp) }
try src.write(to: tmp, atomically: true, encoding: .utf8)
let choices = try await ModelCatalogLoader.load(from: tmp.path)
#expect(choices.isEmpty)
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
import Testing
@testable import Moltbot
@Suite struct NodeManagerPathsTests {
private func makeTempDir() 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
}
private func makeExec(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)
}
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
let home = try self.makeTempDir()
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 self.makeExec(at: v20Bin)
try self.makeExec(at: v25Bin)
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
#expect(bins.first == v25Bin.deletingLastPathComponent().path)
#expect(bins.contains(v20Bin.deletingLastPathComponent().path))
}
@Test func ignoresEntriesWithoutNodeExecutable() throws {
let home = try self.makeTempDir()
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,10 @@
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct NodePairingApprovalPrompterTests {
@Test func nodePairingApprovalPrompterExercises() async {
await NodePairingApprovalPrompter.exerciseForTesting()
}
}

View File

@@ -0,0 +1,14 @@
import Testing
@testable import Moltbot
@Suite struct NodePairingReconcilePolicyTests {
@Test func policyPollsOnlyWhenActive() {
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: false) == false)
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 1, isPresenting: false))
#expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: true))
}
@Test func policyUsesSlowSafetyInterval() {
#expect(NodePairingReconcilePolicy.activeIntervalMs >= 10000)
}
}

View File

@@ -0,0 +1,10 @@
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct OnboardingCoverageTests {
@Test func exerciseOnboardingPages() {
OnboardingView.exerciseForTesting()
}
}

View File

@@ -0,0 +1,28 @@
import MoltbotDiscovery
import SwiftUI
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct OnboardingViewSmokeTests {
@Test func onboardingViewBuildsBody() {
let state = AppState(preview: true)
let view = OnboardingView(
state: state,
permissionMonitor: PermissionMonitor.shared,
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
_ = view.body
}
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
#expect(!order.contains(7))
#expect(order.contains(3))
}
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
#expect(!order.contains(8))
}
}

View File

@@ -0,0 +1,44 @@
import MoltbotProtocol
import SwiftUI
import Testing
@testable import Moltbot
private typealias ProtoAnyCodable = MoltbotProtocol.AnyCodable
@Suite(.serialized)
@MainActor
struct OnboardingWizardStepViewTests {
@Test func noteStepBuilds() {
let step = WizardStep(
id: "step-1",
type: ProtoAnyCodable("note"),
title: "Welcome",
message: "Hello",
options: nil,
initialvalue: nil,
placeholder: nil,
sensitive: nil,
executor: nil)
let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in })
_ = view.body
}
@Test func selectStepBuilds() {
let options: [[String: ProtoAnyCodable]] = [
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")],
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")],
]
let step = WizardStep(
id: "step-2",
type: ProtoAnyCodable("select"),
title: "Mode",
message: "Choose a mode",
options: options,
initialvalue: ProtoAnyCodable("local"),
placeholder: nil,
sensitive: nil,
executor: nil)
let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in })
_ = view.body
}
}

View File

@@ -0,0 +1,20 @@
import CoreLocation
import Testing
@testable import Moltbot
@Suite("PermissionManager Location")
struct PermissionManagerLocationTests {
@Test("authorizedAlways counts for both modes")
func authorizedAlwaysCountsForBothModes() {
#expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: false))
#expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: true))
}
@Test("other statuses not authorized")
func otherStatusesNotAuthorized() {
#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 MoltbotIPC
import CoreLocation
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct PermissionManagerTests {
@Test func voiceWakePermissionHelpersMatchStatus() async {
let direct = PermissionManager.voiceWakePermissionsGranted()
let ensured = await PermissionManager.ensureVoiceWakePermissions(interactive: false)
#expect(ensured == direct)
}
@Test func statusCanQueryNonInteractiveCaps() async {
let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording]
let status = await PermissionManager.status(caps)
#expect(status.keys.count == caps.count)
}
@Test func ensureNonInteractiveDoesNotThrow() async {
let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording]
let ensured = await PermissionManager.ensure(caps, interactive: false)
#expect(ensured.keys.count == caps.count)
}
@Test func locationStatusMatchesAuthorizationAlways() async {
let status = CLLocationManager().authorizationStatus
let results = await PermissionManager.status([.location])
#expect(results[.location] == (status == .authorizedAlways))
}
@Test func ensureLocationNonInteractiveMatchesAuthorizationAlways() 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
@Suite struct PlaceholderTests {
@Test func placeholder() {
#expect(true)
}
}

View File

@@ -0,0 +1,74 @@
import Testing
@testable import Moltbot
#if canImport(Darwin)
import Darwin
import Foundation
@Suite struct RemotePortTunnelTests {
@Test func drainStderrDoesNotCrashWhenHandleClosed() {
let pipe = Pipe()
let handle = pipe.fileHandleForReading
try? handle.close()
let drained = RemotePortTunnel._testDrainStderr(handle)
#expect(drained.isEmpty)
}
@Test func portIsFreeDetectsIPv4Listener() {
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)
}
}
#endif

View File

@@ -0,0 +1,71 @@
import Foundation
import Testing
@testable import Moltbot
@Suite 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 resolveSucceedsWithValidNode() throws {
let script = """
#!/bin/sh
echo v22.5.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: 5, patch: 0))
}
@Test func resolveFailsWhenTooOld() 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 resolveFailsWhenVersionUnparsable() 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 describeFailureIncludesPaths() {
let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"]))
#expect(msg.contains("PATH searched: /tmp/a:/tmp/b"))
}
@Test func runtimeVersionParsesWithLeadingVAndMetadata() {
#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,21 @@
import Foundation
import Testing
@testable import Moltbot
@Suite
struct ScreenshotSizeTests {
@Test
func readPNGSizeReturnsDimensions() 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 readPNGSizeRejectsNonPNGData() {
#expect(ScreenshotSize.readPNGSize(data: Data("nope".utf8)) == nil)
}
}

View File

@@ -0,0 +1,21 @@
import Testing
@testable import Moltbot
@Suite struct SemverTests {
@Test func comparisonOrdersByMajorMinorPatch() {
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 descriptionMatchesParts() {
let v = Semver(major: 3, minor: 2, patch: 1)
#expect(v.description == "3.2.1")
}
}

View File

@@ -0,0 +1,48 @@
import Foundation
import Testing
@testable import Moltbot
@Suite
struct SessionDataTests {
@Test func sessionKindFromKeyDetectsCommonKinds() {
#expect(SessionKind.from(key: "global") == .global)
#expect(SessionKind.from(key: "discord:group:engineering") == .group)
#expect(SessionKind.from(key: "unknown") == .unknown)
#expect(SessionKind.from(key: "user@example.com") == .direct)
}
@Test func sessionTokenStatsFormatKTokensRoundsAsExpected() {
#expect(SessionTokenStats.formatKTokens(999) == "999")
#expect(SessionTokenStats.formatKTokens(1000) == "1.0k")
#expect(SessionTokenStats.formatKTokens(12340) == "12k")
}
@Test func sessionTokenStatsPercentUsedClampsTo100() {
let stats = SessionTokenStats(input: 0, output: 0, total: 250_000, contextTokens: 200_000)
#expect(stats.percentUsed == 100)
}
@Test func sessionRowFlagLabelsIncludeNonDefaultFlags() {
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 Moltbot
@Suite(.serialized)
struct SessionMenuPreviewTests {
@Test func loaderReturnsCachedItems() 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 loaderReturnsEmptyWhenCachedEmpty() 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,165 @@
import SwiftUI
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct SettingsViewSmokeTests {
@Test func cronSettingsBuildsBody() {
let store = CronJobsStore(isPreview: true)
store.schedulerEnabled = false
store.schedulerStorePath = "/tmp/moltbot-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"),
isolation: 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: true,
channel: "sms",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
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 cronSettingsExercisesPrivateViews() {
CronSettings.exerciseForTesting()
}
@Test func configSettingsBuildsBody() {
let view = ConfigSettings()
_ = view.body
}
@Test func debugSettingsBuildsBody() {
let view = DebugSettings()
_ = view.body
}
@Test func generalSettingsBuildsBody() {
let state = AppState(preview: true)
let view = GeneralSettings(state: state)
_ = view.body
}
@Test func generalSettingsExercisesBranches() {
GeneralSettings.exerciseForTesting()
}
@Test func sessionsSettingsBuildsBody() {
let view = SessionsSettings(rows: SessionRow.previewRows, isPreview: true)
_ = view.body
}
@Test func instancesSettingsBuildsBody() {
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 permissionsSettingsBuildsBody() {
let view = PermissionsSettings(
status: [
.notifications: true,
.screenRecording: false,
],
refresh: {},
showOnboarding: {})
_ = view.body
}
@Test func settingsRootViewBuildsBody() {
let state = AppState(preview: true)
let view = SettingsRootView(state: state, updater: nil, initialTab: .general)
_ = view.body
}
@Test func aboutSettingsBuildsBody() {
let view = AboutSettings(updater: nil)
_ = view.body
}
@Test func voiceWakeSettingsBuildsBody() {
let state = AppState(preview: true)
let view = VoiceWakeSettings(state: state, isActive: false)
_ = view.body
}
@Test func skillsSettingsBuildsBody() {
let view = SkillsSettings(state: .preview)
_ = view.body
}
}

View File

@@ -0,0 +1,119 @@
import MoltbotProtocol
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct SkillsSettingsSmokeTests {
@Test func skillsSettingsBuildsBodyWithSkillsRemote() {
let model = SkillsSettingsModel()
model.statusMessage = "Loaded"
model.skills = [
SkillStatus(
name: "Needs Setup",
description: "Missing bins and env",
source: "moltbot-managed",
filePath: "/tmp/skills/needs-setup",
baseDir: "/tmp/skills",
skillKey: "needs-setup",
primaryEnv: "API_KEY",
emoji: "🧰",
homepage: "https://example.com/needs-setup",
always: false,
disabled: false,
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"]),
]),
SkillStatus(
name: "Ready Skill",
description: "All set",
source: "moltbot-bundled",
filePath: "/tmp/skills/ready",
baseDir: "/tmp/skills",
skillKey: "ready",
primaryEnv: nil,
emoji: "",
homepage: "https://example.com/ready",
always: false,
disabled: false,
eligible: true,
requirements: SkillRequirements(bins: [], env: [], config: []),
missing: SkillMissing(bins: [], env: [], config: []),
configChecks: [
SkillStatusConfigCheck(path: "skills.ready", value: AnyCodable(true), satisfied: true),
SkillStatusConfigCheck(path: "skills.limit", value: AnyCodable(5), satisfied: true),
],
install: []),
SkillStatus(
name: "Disabled Skill",
description: "Disabled in config",
source: "moltbot-extra",
filePath: "/tmp/skills/disabled",
baseDir: "/tmp/skills",
skillKey: "disabled",
primaryEnv: nil,
emoji: "🚫",
homepage: nil,
always: false,
disabled: true,
eligible: false,
requirements: SkillRequirements(bins: [], env: [], config: []),
missing: SkillMissing(bins: [], env: [], config: []),
configChecks: [],
install: []),
]
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 skillsSettingsBuildsBodyWithLocalMode() {
let model = SkillsSettingsModel()
model.skills = [
SkillStatus(
name: "Local Skill",
description: "Local ready",
source: "moltbot-workspace",
filePath: "/tmp/skills/local",
baseDir: "/tmp/skills",
skillKey: "local",
primaryEnv: nil,
emoji: "🏠",
homepage: nil,
always: false,
disabled: false,
eligible: true,
requirements: SkillRequirements(bins: [], env: [], config: []),
missing: SkillMissing(bins: [], env: [], config: []),
configChecks: [],
install: []),
]
let state = AppState(preview: true)
state.connectionMode = .local
var view = SkillsSettings(state: state, model: model)
view.setFilterForTesting("ready")
_ = view.body
}
@Test func skillsSettingsExercisesPrivateViews() {
SkillsSettings.exerciseForTesting()
}
}

View File

@@ -0,0 +1,48 @@
import SwiftUI
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct TailscaleIntegrationSectionTests {
@Test func tailscaleSectionBuildsBodyWhenNotInstalled() {
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 tailscaleSectionBuildsBodyForServeMode() {
let service = TailscaleService(
isInstalled: true,
isRunning: true,
tailscaleHostname: "moltbot.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 tailscaleSectionBuildsBodyForFunnelMode() {
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
}
}

View File

@@ -0,0 +1,97 @@
import Foundation
import Testing
@testable import Moltbot
@Suite(.serialized) struct TalkAudioPlayerTests {
@MainActor
@Test func playDoesNotHangWhenPlaybackEndsOrFails() async throws {
let wav = makeWav16Mono(sampleRate: 8000, samples: 80)
defer { _ = TalkAudioPlayer.shared.stop() }
_ = try await withTimeout(seconds: 4.0) {
await TalkAudioPlayer.shared.play(data: wav)
}
#expect(true)
}
@MainActor
@Test func playDoesNotHangWhenPlayIsCalledTwice() 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: 4.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,116 @@
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
{
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()
for (key, value) in previousDefaults {
if let value {
userDefaults.set(value, forKey: key)
} else {
userDefaults.removeObject(forKey: key)
}
}
for (key, value) in previousEnv {
if let value {
setenv(key, value, 1)
} else {
unsetenv(key)
}
}
await TestIsolationLock.shared.release()
return result
} catch {
for (key, value) in previousDefaults {
if let value {
userDefaults.set(value, forKey: key)
} else {
userDefaults.removeObject(forKey: key)
}
}
for (key, value) in previousEnv {
if let value {
setenv(key, value, 1)
} else {
unsetenv(key)
}
}
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("moltbot-test-config-\(UUID().uuidString).json")
.path
}
}

View File

@@ -0,0 +1,83 @@
import Foundation
import Testing
@testable import Moltbot
@Suite(.serialized) struct UtilitiesTests {
@Test func ageStringsCoverCommonWindows() {
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 parseSSHTargetSupportsUserPortAndDefaults() {
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 sanitizedTargetStripsLeadingSSHPrefix() {
let defaults = 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 gatewayEntrypointPrefersDistOverBin() 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/moltbot.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 logLocatorPicksNewestLogFile() throws {
let fm = FileManager()
let dir = URL(fileURLWithPath: "/tmp/moltbot", isDirectory: true)
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
let older = dir.appendingPathComponent("moltbot-old-\(UUID().uuidString).log")
let newer = dir.appendingPathComponent("moltbot-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 gatewayEntrypointNilWhenMissing() {
let tmp = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
#expect(CommandResolver.gatewayEntrypoint(in: tmp) == nil)
}
}

View File

@@ -0,0 +1,37 @@
import AppKit
import Testing
@testable import Moltbot
@Suite(.serialized) struct VoicePushToTalkHotkeyTests {
actor Counter {
private(set) var began = 0
private(set) var ended = 0
func incBegin() { self.began += 1 }
func incEnd() { self.ended += 1 }
func snapshot() -> (began: Int, ended: Int) { (self.began, self.ended) }
}
@Test func beginEndFiresOncePerHold() async {
let counter = Counter()
let hotkey = VoicePushToTalkHotkey(
beginAction: { await counter.incBegin() },
endAction: { await counter.incEnd() })
await MainActor.run {
hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [.option])
hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [.option])
hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [])
}
for _ in 0..<50 {
let snap = await counter.snapshot()
if snap.began == 1, snap.ended == 1 { break }
try? await Task.sleep(nanoseconds: 10_000_000)
}
let snap = await counter.snapshot()
#expect(snap.began == 1)
#expect(snap.ended == 1)
}
}

View File

@@ -0,0 +1,24 @@
import Testing
@testable import Moltbot
@Suite struct VoicePushToTalkTests {
@Test func deltaTrimsCommittedPrefix() {
let delta = VoicePushToTalk._testDelta(committed: "hello ", current: "hello world again")
#expect(delta == "world again")
}
@Test func deltaFallsBackWhenPrefixDiffers() {
let delta = VoicePushToTalk._testDelta(committed: "goodbye", current: "hello world")
#expect(delta == "hello world")
}
@Test func attributedColorsDifferWhenNotFinal() {
let colors = VoicePushToTalk._testAttributedColors(isFinal: false)
#expect(colors.0 != colors.1)
}
@Test func attributedColorsMatchWhenFinal() {
let colors = VoicePushToTalk._testAttributedColors(isFinal: true)
#expect(colors.0 == colors.1)
}
}

View File

@@ -0,0 +1,22 @@
import Testing
@testable import Moltbot
@Suite(.serialized) struct VoiceWakeForwarderTests {
@Test func prefixedTranscriptUsesMachineName() {
let transcript = "hello world"
let prefixed = VoiceWakeForwarder.prefixedTranscript(transcript, machineName: "My-Mac")
#expect(prefixed.starts(with: "User talked via voice recognition on"))
#expect(prefixed.contains("My-Mac"))
#expect(prefixed.hasSuffix("\n\nhello world"))
}
@Test func forwardOptionsDefaults() {
let opts = VoiceWakeForwarder.ForwardOptions()
#expect(opts.sessionKey == "main")
#expect(opts.thinking == "low")
#expect(opts.deliver == true)
#expect(opts.to == nil)
#expect(opts.channel == .last)
}
}

View File

@@ -0,0 +1,56 @@
import MoltbotProtocol
import Foundation
import Testing
@testable import Moltbot
@Suite(.serialized) struct VoiceWakeGlobalSettingsSyncTests {
@Test func appliesVoiceWakeChangedEventToAppState() async {
let previous = await MainActor.run { AppStateStore.shared.swabbleTriggerWords }
await MainActor.run {
AppStateStore.shared.applyGlobalVoiceWakeTriggers(["before"])
}
let payload = MoltbotProtocol.AnyCodable(["triggers": ["clawd", "computer"]])
let evt = EventFrame(
type: "event",
event: "voicewake.changed",
payload: payload,
seq: nil,
stateversion: nil)
await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt))
let updated = await MainActor.run { AppStateStore.shared.swabbleTriggerWords }
#expect(updated == ["clawd", "computer"])
await MainActor.run {
AppStateStore.shared.applyGlobalVoiceWakeTriggers(previous)
}
}
@Test func ignoresVoiceWakeChangedEventWithInvalidPayload() async {
let previous = await MainActor.run { AppStateStore.shared.swabbleTriggerWords }
await MainActor.run {
AppStateStore.shared.applyGlobalVoiceWakeTriggers(["before"])
}
let payload = MoltbotProtocol.AnyCodable(["unexpected": 123])
let evt = EventFrame(
type: "event",
event: "voicewake.changed",
payload: payload,
seq: nil,
stateversion: nil)
await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt))
let updated = await MainActor.run { AppStateStore.shared.swabbleTriggerWords }
#expect(updated == ["before"])
await MainActor.run {
AppStateStore.shared.applyGlobalVoiceWakeTriggers(previous)
}
}
}

View File

@@ -0,0 +1,35 @@
import Testing
@testable import Moltbot
struct VoiceWakeHelpersTests {
@Test func sanitizeTriggersTrimsAndDropsEmpty() {
let cleaned = sanitizeVoiceWakeTriggers([" hi ", " ", "\n", "there"])
#expect(cleaned == ["hi", "there"])
}
@Test func sanitizeTriggersFallsBackToDefaults() {
let cleaned = sanitizeVoiceWakeTriggers([" ", ""])
#expect(cleaned == defaultVoiceWakeTriggers)
}
@Test func sanitizeTriggersLimitsWordLength() {
let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5)
let cleaned = sanitizeVoiceWakeTriggers(["ok", long])
#expect(cleaned[1].count == voiceWakeMaxWordLength)
}
@Test func sanitizeTriggersLimitsWordCount() {
let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" }
let cleaned = sanitizeVoiceWakeTriggers(words)
#expect(cleaned.count == voiceWakeMaxWords)
}
@Test func normalizeLocaleStripsCollation() {
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
}
@Test func normalizeLocaleStripsUnicodeExtensions() {
#expect(normalizeLocaleIdentifier("de-DE-u-co-phonebk") == "de-DE")
#expect(normalizeLocaleIdentifier("ja-JP-t-ja") == "ja-JP")
}
}

View File

@@ -0,0 +1,68 @@
import Foundation
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct VoiceWakeOverlayControllerTests {
@Test func overlayControllerLifecycleWithoutUI() async {
let controller = VoiceWakeOverlayController(enableUI: false)
let token = controller.startSession(
source: .wakeWord,
transcript: "hello",
attributed: nil,
forwardEnabled: true,
isFinal: false)
#expect(controller.snapshot().token == token)
#expect(controller.snapshot().isVisible == true)
controller.updatePartial(token: token, transcript: "hello world")
#expect(controller.snapshot().text == "hello world")
controller.updateLevel(token: token, -0.5)
#expect(controller.model.level == 0)
try? await Task.sleep(nanoseconds: 120_000_000)
controller.updateLevel(token: token, 2.0)
#expect(controller.model.level == 1)
controller.dismiss(token: token, reason: .explicit, outcome: .empty)
#expect(controller.snapshot().isVisible == false)
#expect(controller.snapshot().token == nil)
}
@Test func evaluateTokenDropsMismatchAndNoActive() {
let active = UUID()
#expect(VoiceWakeOverlayController.evaluateToken(active: nil, incoming: active) == .dropNoActive)
#expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: UUID()) == .dropMismatch)
#expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: active) == .accept)
#expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: nil) == .accept)
}
@Test func updateLevelThrottlesRapidChanges() async {
let controller = VoiceWakeOverlayController(enableUI: false)
let token = controller.startSession(
source: .wakeWord,
transcript: "level test",
attributed: nil,
forwardEnabled: false,
isFinal: false)
controller.updateLevel(token: token, 0.25)
let first = controller.model.level
controller.updateLevel(token: token, 0.9)
#expect(controller.model.level == first)
controller.updateLevel(token: token, 0)
#expect(controller.model.level == 0)
try? await Task.sleep(nanoseconds: 120_000_000)
controller.updateLevel(token: token, 0.9)
#expect(controller.model.level == 0.9)
}
@Test func overlayControllerExercisesHelpers() async {
await VoiceWakeOverlayController.exerciseForTesting()
}
}

View File

@@ -0,0 +1,21 @@
import Foundation
import Testing
@testable import Moltbot
@Suite struct VoiceWakeOverlayTests {
@Test func guardTokenDropsWhenNoActive() {
let outcome = VoiceWakeOverlayController.evaluateToken(active: nil, incoming: UUID())
#expect(outcome == .dropNoActive)
}
@Test func guardTokenAcceptsMatching() {
let token = UUID()
let outcome = VoiceWakeOverlayController.evaluateToken(active: token, incoming: token)
#expect(outcome == .accept)
}
@Test func guardTokenDropsMismatchWithoutDismissing() {
let outcome = VoiceWakeOverlayController.evaluateToken(active: UUID(), incoming: UUID())
#expect(outcome == .dropMismatch)
}
}

View File

@@ -0,0 +1,28 @@
import SwiftUI
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct VoiceWakeOverlayViewSmokeTests {
@Test func overlayViewBuildsBodyInDisplayMode() {
let controller = VoiceWakeOverlayController(enableUI: false)
_ = controller.startSession(source: .wakeWord, transcript: "hello", forwardEnabled: true)
let view = VoiceWakeOverlayView(controller: controller)
_ = view.body
}
@Test func overlayViewBuildsBodyInEditingMode() {
let controller = VoiceWakeOverlayController(enableUI: false)
let token = controller.startSession(source: .pushToTalk, transcript: "edit me", forwardEnabled: true)
controller.userBeganEditing()
controller.updateLevel(token: token, 0.6)
let view = VoiceWakeOverlayView(controller: controller)
_ = view.body
}
@Test func closeButtonOverlayBuildsBody() {
let view = CloseButtonOverlay(isVisible: true, onHover: { _ in }, onClose: {})
_ = view.body
}
}

View File

@@ -0,0 +1,79 @@
import Foundation
import SwabbleKit
import Testing
@testable import Moltbot
@Suite struct VoiceWakeRuntimeTests {
@Test func trimsAfterTriggerKeepsPostSpeech() {
let triggers = ["claude", "clawd"]
let text = "hey Claude how are you"
#expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "how are you")
}
@Test func trimsAfterTriggerReturnsOriginalWhenNoTrigger() {
let triggers = ["claude"]
let text = "good morning friend"
#expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == text)
}
@Test func trimsAfterFirstMatchingTrigger() {
let triggers = ["buddy", "claude"]
let text = "hello buddy this is after trigger claude also here"
#expect(VoiceWakeRuntime
._testTrimmedAfterTrigger(text, triggers: triggers) == "this is after trigger claude also here")
}
@Test func hasContentAfterTriggerFalseWhenOnlyTrigger() {
let triggers = ["clawd"]
let text = "hey clawd"
#expect(!VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers))
}
@Test func hasContentAfterTriggerTrueWhenSpeechContinues() {
let triggers = ["claude"]
let text = "claude write a note"
#expect(VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers))
}
@Test func gateRequiresGapBetweenTriggerAndCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
}
@Test func gateAcceptsGapAndExtractsCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}

View File

@@ -0,0 +1,47 @@
import Foundation
import SwabbleKit
import Testing
struct VoiceWakeTesterTests {
@Test func matchRespectsGapRequirement() {
let transcript = "hey claude do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("claude", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["claude"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
}
@Test func matchReturnsCommandAfterGap() {
let transcript = "hey claude do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("claude", 0.2, 0.1),
("do", 0.8, 0.1),
("thing", 1.0, 0.1),
])
let config = WakeWordGateConfig(triggers: ["claude"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}

View File

@@ -0,0 +1,67 @@
import Foundation
import Testing
@testable import Moltbot
@Suite struct WebChatMainSessionKeyTests {
@Test func configGetSnapshotMainKeyFallsBackToMainWhenMissing() throws {
let json = """
{
"path": "/Users/pete/.clawdbot/moltbot.json",
"exists": true,
"raw": null,
"parsed": {},
"valid": true,
"config": {},
"issues": []
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "main")
}
@Test func configGetSnapshotMainKeyTrimsAndUsesValue() throws {
let json = """
{
"path": "/Users/pete/.clawdbot/moltbot.json",
"exists": true,
"raw": null,
"parsed": {},
"valid": true,
"config": { "session": { "mainKey": " primary " } },
"issues": []
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "main")
}
@Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws {
let json = """
{
"config": { "session": { "mainKey": " " } }
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "main")
}
@Test func configGetSnapshotMainKeyFallsBackWhenConfigNull() throws {
let json = """
{
"config": null
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "main")
}
@Test func configGetSnapshotUsesGlobalScope() throws {
let json = """
{
"config": { "session": { "scope": "global" } }
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "global")
}
}

View File

@@ -0,0 +1,11 @@
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct WebChatManagerTests {
@Test func preferredSessionKeyIsNonEmpty() async {
let key = await WebChatManager.shared.preferredSessionKey()
#expect(!key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}

View File

@@ -0,0 +1,60 @@
import AppKit
import MoltbotChatUI
import Foundation
import Testing
@testable import Moltbot
@Suite(.serialized)
@MainActor
struct WebChatSwiftUISmokeTests {
private struct TestTransport: MoltbotChatTransport, Sendable {
func requestHistory(sessionKey: String) async throws -> MoltbotChatHistoryPayload {
let json = """
{"sessionKey":"\(sessionKey)","sessionId":null,"messages":[],"thinkingLevel":"off"}
"""
return try JSONDecoder().decode(MoltbotChatHistoryPayload.self, from: Data(json.utf8))
}
func sendMessage(
sessionKey _: String,
message _: String,
thinking _: String,
idempotencyKey _: String,
attachments _: [MoltbotChatAttachmentPayload]) async throws -> MoltbotChatSendResponse
{
let json = """
{"runId":"\(UUID().uuidString)","status":"ok"}
"""
return try JSONDecoder().decode(MoltbotChatSendResponse.self, from: Data(json.utf8))
}
func requestHealth(timeoutMs _: Int) async throws -> Bool { true }
func events() -> AsyncStream<MoltbotChatTransportEvent> {
AsyncStream { continuation in
continuation.finish()
}
}
func setActiveSessionKey(_: String) async throws {}
}
@Test func windowControllerShowAndClose() {
let controller = WebChatSwiftUIWindowController(
sessionKey: "main",
presentation: .window,
transport: TestTransport())
controller.show()
controller.close()
}
@Test func panelControllerPresentAndClose() {
let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) }
let controller = WebChatSwiftUIWindowController(
sessionKey: "main",
presentation: .panel(anchorProvider: anchor),
transport: TestTransport())
controller.presentAnchored(anchorProvider: anchor)
controller.close()
}
}

View File

@@ -0,0 +1,49 @@
import Testing
@testable import MoltbotDiscovery
@Suite
struct WideAreaGatewayDiscoveryTests {
@Test func discoversBeaconFromTailnetDnsSdFallback() {
let statusJson = """
{
"Self": { "TailscaleIPs": ["100.69.232.64"] },
"Peer": {
"peer-1": { "TailscaleIPs": ["100.123.224.76"] }
}
}
"""
let context = WideAreaGatewayDiscovery.DiscoveryContext(
tailscaleStatus: { statusJson },
dig: { args, _ in
let recordType = args.last ?? ""
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
if recordType == "PTR" {
if nameserver == "@100.123.224.76" {
return "steipetacstudio-gateway._moltbot-gw._tcp.clawdbot.internal.\n"
}
return ""
}
if recordType == "SRV" {
return "0 0 18789 steipetacstudio.clawdbot.internal."
}
if recordType == "TXT" {
return "\"displayName=Peter\\226\\128\\153s Mac Studio (Moltbot)\" \"gatewayPort=18789\" \"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net\" \"cliPath=/Users/steipete/moltbot/src/entry.ts\""
}
return ""
})
let beacons = WideAreaGatewayDiscovery.discover(
timeoutSeconds: 2.0,
context: context)
#expect(beacons.count == 1)
let beacon = beacons[0]
let expectedDisplay = "Peter\u{2019}s Mac Studio (Moltbot)"
#expect(beacon.displayName == expectedDisplay)
#expect(beacon.port == 18789)
#expect(beacon.gatewayPort == 18789)
#expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net")
#expect(beacon.cliPath == "/Users/steipete/moltbot/src/entry.ts")
}
}

View File

@@ -0,0 +1,85 @@
import AppKit
import Testing
@testable import Moltbot
@Suite
@MainActor
struct WindowPlacementTests {
@Test
func centeredFrameZeroBoundsFallsBackToOrigin() {
let frame = WindowPlacement.centeredFrame(size: NSSize(width: 120, height: 80), in: NSRect.zero)
#expect(frame.origin == .zero)
#expect(frame.size == NSSize(width: 120, height: 80))
}
@Test
func centeredFrameClampsToBoundsAndCenters() {
let bounds = NSRect(x: 10, y: 20, width: 300, height: 200)
let frame = WindowPlacement.centeredFrame(size: NSSize(width: 600, height: 120), in: bounds)
#expect(frame.size.width == bounds.width)
#expect(frame.size.height == 120)
#expect(frame.minX == bounds.minX)
#expect(frame.midY == bounds.midY)
}
@Test
func topRightFrameZeroBoundsFallsBackToOrigin() {
let frame = WindowPlacement.topRightFrame(
size: NSSize(width: 120, height: 80),
padding: 12,
in: NSRect.zero)
#expect(frame.origin == .zero)
#expect(frame.size == NSSize(width: 120, height: 80))
}
@Test
func topRightFrameClampsToBoundsAndAppliesPadding() {
let bounds = NSRect(x: 10, y: 20, width: 300, height: 200)
let frame = WindowPlacement.topRightFrame(
size: NSSize(width: 400, height: 50),
padding: 8,
in: bounds)
#expect(frame.size.width == bounds.width)
#expect(frame.size.height == 50)
#expect(frame.maxX == bounds.maxX - 8)
#expect(frame.maxY == bounds.maxY - 8)
}
@Test
func ensureOnScreenUsesFallbackWhenWindowOffscreen() {
let window = NSWindow(
contentRect: NSRect(x: 100_000, y: 100_000, width: 200, height: 120),
styleMask: [.borderless],
backing: .buffered,
defer: false)
WindowPlacement.ensureOnScreen(
window: window,
defaultSize: NSSize(width: 200, height: 120),
fallback: { _ in NSRect(x: 11, y: 22, width: 33, height: 44) })
#expect(window.frame == NSRect(x: 11, y: 22, width: 33, height: 44))
}
@Test
func ensureOnScreenDoesNotMoveVisibleWindow() {
let screen = NSScreen.main ?? NSScreen.screens.first
#expect(screen != nil)
guard let screen else { return }
let visible = screen.visibleFrame.insetBy(dx: 40, dy: 40)
let window = NSWindow(
contentRect: NSRect(x: visible.minX, y: visible.minY, width: 200, height: 120),
styleMask: [.titled],
backing: .buffered,
defer: false)
let original = window.frame
WindowPlacement.ensureOnScreen(
window: window,
defaultSize: NSSize(width: 200, height: 120),
fallback: { _ in NSRect(x: 11, y: 22, width: 33, height: 44) })
#expect(window.frame == original)
}
}

View File

@@ -0,0 +1,99 @@
import MoltbotProtocol
import Foundation
import Testing
@testable import Moltbot
@Suite
@MainActor
struct WorkActivityStoreTests {
@Test func mainSessionJobPreemptsOther() {
let store = WorkActivityStore()
store.handleJob(sessionKey: "discord:group:1", state: "started")
#expect(store.iconState == .workingOther(.job))
#expect(store.current?.sessionKey == "discord:group:1")
store.handleJob(sessionKey: "main", state: "started")
#expect(store.iconState == .workingMain(.job))
#expect(store.current?.sessionKey == "main")
store.handleJob(sessionKey: "main", state: "finished")
#expect(store.iconState == .workingOther(.job))
#expect(store.current?.sessionKey == "discord:group:1")
store.handleJob(sessionKey: "discord:group:1", state: "finished")
#expect(store.iconState == .idle)
#expect(store.current == nil)
}
@Test func jobStaysWorkingAfterToolResultGrace() async {
let store = WorkActivityStore()
store.handleJob(sessionKey: "main", state: "started")
#expect(store.iconState == .workingMain(.job))
store.handleTool(
sessionKey: "main",
phase: "start",
name: "read",
meta: nil,
args: ["path": AnyCodable("/tmp/file.txt")])
#expect(store.iconState == .workingMain(.tool(.read)))
store.handleTool(
sessionKey: "main",
phase: "result",
name: "read",
meta: nil,
args: ["path": AnyCodable("/tmp/file.txt")])
for _ in 0..<50 {
if store.iconState == .workingMain(.job) { break }
try? await Task.sleep(nanoseconds: 100_000_000)
}
#expect(store.iconState == .workingMain(.job))
store.handleJob(sessionKey: "main", state: "done")
#expect(store.iconState == .idle)
}
@Test func toolLabelExtractsFirstLineAndShortensHome() {
let store = WorkActivityStore()
let home = NSHomeDirectory()
store.handleTool(
sessionKey: "main",
phase: "start",
name: "bash",
meta: nil,
args: [
"command": AnyCodable("echo hi\necho bye"),
"path": AnyCodable("\(home)/Projects/moltbot"),
])
#expect(store.current?.label == "bash: echo hi")
#expect(store.iconState == .workingMain(.tool(.bash)))
store.handleTool(
sessionKey: "main",
phase: "start",
name: "read",
meta: nil,
args: ["path": AnyCodable("\(home)/secret.txt")])
#expect(store.current?.label == "read: ~/secret.txt")
#expect(store.iconState == .workingMain(.tool(.read)))
}
@Test func resolveIconStateHonorsOverrideSelection() {
let store = WorkActivityStore()
store.handleJob(sessionKey: "main", state: "started")
#expect(store.iconState == .workingMain(.job))
store.resolveIconState(override: .idle)
#expect(store.iconState == .idle)
store.resolveIconState(override: .otherEdit)
#expect(store.iconState == .overridden(.tool(.edit)))
}
}