Add ez-assistant and kerberos service folders
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Testing
|
||||
@testable import Moltbot
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct NodePairingApprovalPrompterTests {
|
||||
@Test func nodePairingApprovalPrompterExercises() async {
|
||||
await NodePairingApprovalPrompter.exerciseForTesting()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Testing
|
||||
@testable import Moltbot
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct OnboardingCoverageTests {
|
||||
@Test func exerciseOnboardingPages() {
|
||||
OnboardingView.exerciseForTesting()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Testing
|
||||
|
||||
@Suite struct PlaceholderTests {
|
||||
@Test func placeholder() {
|
||||
#expect(true)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user