refactor(macos): inject main-actor services into node runtime

This commit is contained in:
Peter Steinberger
2026-01-07 20:20:52 +00:00
parent 5a09926126
commit eb5f0b73a9
5 changed files with 162 additions and 90 deletions

View File

@@ -93,34 +93,22 @@ struct LowCoverageHelperTests {
_ = PresenceReporter._testPrimaryIPv4Address()
}
@Test func gatewayLaunchAgentHelpers() {
let keyBind = "CLAWDBOT_GATEWAY_BIND"
let keyToken = "CLAWDBOT_GATEWAY_TOKEN"
let previousBind = ProcessInfo.processInfo.environment[keyBind]
let previousToken = ProcessInfo.processInfo.environment[keyToken]
defer {
if let previousBind {
setenv(keyBind, previousBind, 1)
} else {
unsetenv(keyBind)
}
if let previousToken {
setenv(keyToken, previousToken, 1)
} else {
unsetenv(keyToken)
}
@Test func gatewayLaunchAgentHelpers() async throws {
try await TestIsolation.withEnvValues(
[
"CLAWDBOT_GATEWAY_BIND": "Lan",
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
])
{
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
#expect(
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
"a&amp;b&lt;c&gt;&quot;&apos;")
#expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
}
setenv(keyBind, "Lan", 1)
setenv(keyToken, " secret ", 1)
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
#expect(
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
"a&amp;b&lt;c&gt;&quot;&apos;")
#expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
}
@Test func portGuardianParsesListenersAndBuildsReports() {

View File

@@ -1,9 +1,9 @@
import ClawdbotKit
import CoreLocation
import Foundation
import Testing
@testable import Clawdbot
@Suite(.serialized)
struct MacNodeRuntimeTests {
@Test func handleInvokeRejectsUnknownCommand() async {
let runtime = MacNodeRuntime()
@@ -31,21 +31,58 @@ struct MacNodeRuntimeTests {
}
@Test func handleInvokeCameraListRequiresEnabledCamera() async {
let defaults = UserDefaults.standard
let previous = defaults.object(forKey: cameraEnabledKey)
defaults.set(false, forKey: cameraEnabledKey)
defer {
if let previous {
defaults.set(previous, forKey: cameraEnabledKey)
} else {
defaults.removeObject(forKey: cameraEnabledKey)
await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) {
let runtime = MacNodeRuntime()
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.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.default.temporaryDirectory
.appendingPathComponent("clawdbot-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: ClawdbotLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
CLLocation(latitude: 0, longitude: 0)
}
}
let runtime = MacNodeRuntime()
let services = await MainActor.run { FakeMainActorServices() }
let runtime = MacNodeRuntime(makeMainActorServices: { services })
let params = MacNodeScreenRecordParams(durationMs: 250)
let json = String(data: try JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-4", command: ClawdbotCameraCommand.list.rawValue))
#expect(response.ok == false)
#expect(response.error?.message.contains("CAMERA_DISABLED") == true)
BridgeInvokeRequest(id: "req-5", command: MacNodeScreenCommand.record.rawValue, paramsJSON: json))
#expect(response.ok == true)
let payloadJSON = try #require(response.payloadJSON)
struct Payload: Decodable {
var format: String
var base64: String
}
let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
#expect(payload.format == "mp4")
#expect(!payload.base64.isEmpty)
}
}

View File

@@ -26,7 +26,7 @@ actor TestIsolationLock {
next.resume()
}
func withLock<T>(_ body: () async throws -> T) async rethrows -> T {
func withLock<T: Sendable>(_ body: @Sendable () async throws -> T) async rethrows -> T {
await self.lock()
defer { self.unlock() }
return try await body()
@@ -34,10 +34,10 @@ actor TestIsolationLock {
}
enum TestIsolation {
static func withIsolatedState<T>(
static func withIsolatedState<T: Sendable>(
env: [String: String?] = [:],
defaults: [String: Any?] = [:],
_ body: () async throws -> T) async rethrows -> T
_ body: @Sendable () async throws -> T) async rethrows -> T
{
try await TestIsolationLock.shared.withLock {
var previousEnv: [String: String?] = [:]
@@ -82,16 +82,16 @@ enum TestIsolation {
}
}
static func withEnvValues<T>(
static func withEnvValues<T: Sendable>(
_ values: [String: String?],
_ body: () async throws -> T) async rethrows -> T
_ body: @Sendable () async throws -> T) async rethrows -> T
{
try await Self.withIsolatedState(env: values, defaults: [:], body)
}
static func withUserDefaultsValues<T>(
static func withUserDefaultsValues<T: Sendable>(
_ values: [String: Any?],
_ body: () async throws -> T) async rethrows -> T
_ body: @Sendable () async throws -> T) async rethrows -> T
{
try await Self.withIsolatedState(env: [:], defaults: values, body)
}