test(macos): isolate env + defaults

This commit is contained in:
Peter Steinberger
2026-01-07 20:13:24 +00:00
parent d45fcc44da
commit 5a09926126
4 changed files with 133 additions and 54 deletions

View File

@@ -2,11 +2,12 @@ import Foundation
extension ProcessInfo { extension ProcessInfo {
var isPreview: Bool { var isPreview: Bool {
self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" guard let raw = getenv("XCODE_RUNNING_FOR_PREVIEWS") else { return false }
return String(cString: raw) == "1"
} }
var isNixMode: Bool { var isNixMode: Bool {
if self.environment["CLAWDBOT_NIX_MODE"] == "1" { return true } if let raw = getenv("CLAWDBOT_NIX_MODE"), String(cString: raw) == "1" { return true }
return UserDefaults.standard.bool(forKey: "clawdbot.nixMode") return UserDefaults.standard.bool(forKey: "clawdbot.nixMode")
} }

View File

@@ -5,26 +5,26 @@ import Testing
@Suite(.serialized) @Suite(.serialized)
struct ClawdbotConfigFileTests { struct ClawdbotConfigFileTests {
@Test @Test
func configPathRespectsEnvOverride() { func configPathRespectsEnvOverride() async {
let override = FileManager.default.temporaryDirectory let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
.path .path
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) { await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
#expect(ClawdbotConfigFile.url().path == override) #expect(ClawdbotConfigFile.url().path == override)
} }
} }
@MainActor @MainActor
@Test @Test
func remoteGatewayPortParsesAndMatchesHost() { func remoteGatewayPortParsesAndMatchesHost() async {
let override = FileManager.default.temporaryDirectory let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
.path .path
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) { await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
ClawdbotConfigFile.saveDict([ ClawdbotConfigFile.saveDict([
"gateway": [ "gateway": [
"remote": [ "remote": [
@@ -41,13 +41,13 @@ struct ClawdbotConfigFileTests {
@MainActor @MainActor
@Test @Test
func setRemoteGatewayUrlPreservesScheme() { func setRemoteGatewayUrlPreservesScheme() async {
let override = FileManager.default.temporaryDirectory let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
.path .path
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) { await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
ClawdbotConfigFile.saveDict([ ClawdbotConfigFile.saveDict([
"gateway": [ "gateway": [
"remote": [ "remote": [
@@ -63,33 +63,17 @@ struct ClawdbotConfigFileTests {
} }
@Test @Test
func stateDirOverrideSetsConfigPath() { func stateDirOverrideSetsConfigPath() async {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
.path .path
self.withEnv("CLAWDBOT_CONFIG_PATH", value: nil) { await TestIsolation.withEnvValues([
self.withEnv("CLAWDBOT_STATE_DIR", value: dir) { "CLAWDBOT_CONFIG_PATH": nil,
#expect(ClawdbotConfigFile.stateDirURL().path == dir) "CLAWDBOT_STATE_DIR": dir,
#expect(ClawdbotConfigFile.url().path == "\(dir)/clawdbot.json") ]) {
} #expect(ClawdbotConfigFile.stateDirURL().path == dir)
#expect(ClawdbotConfigFile.url().path == "\(dir)/clawdbot.json")
} }
} }
private func withEnv(_ key: String, value: String?, _ body: () -> Void) {
let previous = ProcessInfo.processInfo.environment[key]
if let value {
setenv(key, value, 1)
} else {
unsetenv(key)
}
defer {
if let previous {
setenv(key, previous, 1)
} else {
unsetenv(key)
}
}
body()
}
} }

View File

@@ -2,7 +2,7 @@ import Foundation
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite(.serialized) struct GatewayEnvironmentTests { @Suite struct GatewayEnvironmentTests {
@Test func semverParsesCommonForms() { @Test func semverParsesCommonForms() {
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3)) #expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0)) #expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
@@ -19,29 +19,19 @@ import Testing
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false) #expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
} }
@Test func gatewayPortDefaultsAndRespectsOverride() { @Test func gatewayPortDefaultsAndRespectsOverride() async {
let envKey = "CLAWDBOT_CONFIG_PATH" let configPath = TestIsolation.tempConfigPath()
let previousEnv = getenv(envKey).map { String(cString: $0) } await TestIsolation.withIsolatedState(
let configPath = FileManager.default.temporaryDirectory env: ["CLAWDBOT_CONFIG_PATH": configPath],
.appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json") defaults: ["gatewayPort": nil])
.path {
setenv(envKey, configPath, 1) let defaultPort = GatewayEnvironment.gatewayPort()
defer { #expect(defaultPort == 18789)
if let previousEnv {
setenv(envKey, previousEnv, 1) UserDefaults.standard.set(19999, forKey: "gatewayPort")
} else { defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
unsetenv(envKey) #expect(GatewayEnvironment.gatewayPort() == 19999)
}
} }
UserDefaults.standard.removeObject(forKey: "gatewayPort")
defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
let defaultPort = GatewayEnvironment.gatewayPort()
#expect(defaultPort == 18789)
UserDefaults.standard.set(19999, forKey: "gatewayPort")
#expect(GatewayEnvironment.gatewayPort() == 19999)
} }
@Test func expectedGatewayVersionFromStringUsesParser() { @Test func expectedGatewayVersionFromStringUsesParser() {

View File

@@ -0,0 +1,104 @@
import Foundation
actor TestIsolationLock {
static let shared = TestIsolationLock()
private var locked = false
private var waiters: [CheckedContinuation<Void, Never>] = []
private func lock() 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.
}
private func unlock() {
if self.waiters.isEmpty {
self.locked = false
return
}
let next = self.waiters.removeFirst()
next.resume()
}
func withLock<T>(_ body: () async throws -> T) async rethrows -> T {
await self.lock()
defer { self.unlock() }
return try await body()
}
}
enum TestIsolation {
static func withIsolatedState<T>(
env: [String: String?] = [:],
defaults: [String: Any?] = [:],
_ body: () async throws -> T) async rethrows -> T
{
try await TestIsolationLock.shared.withLock {
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)
}
}
defer {
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)
}
}
}
return try await body()
}
}
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)
}
static func tempConfigPath() -> String {
FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
.path
}
}