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

View File

@@ -5,26 +5,26 @@ import Testing
@Suite(.serialized)
struct ClawdbotConfigFileTests {
@Test
func configPathRespectsEnvOverride() {
func configPathRespectsEnvOverride() async {
let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
#expect(ClawdbotConfigFile.url().path == override)
}
}
@MainActor
@Test
func remoteGatewayPortParsesAndMatchesHost() {
func remoteGatewayPortParsesAndMatchesHost() async {
let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
ClawdbotConfigFile.saveDict([
"gateway": [
"remote": [
@@ -41,13 +41,13 @@ struct ClawdbotConfigFileTests {
@MainActor
@Test
func setRemoteGatewayUrlPreservesScheme() {
func setRemoteGatewayUrlPreservesScheme() async {
let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) {
await TestIsolation.withEnvValues(["CLAWDBOT_CONFIG_PATH": override]) {
ClawdbotConfigFile.saveDict([
"gateway": [
"remote": [
@@ -63,33 +63,17 @@ struct ClawdbotConfigFileTests {
}
@Test
func stateDirOverrideSetsConfigPath() {
func stateDirOverrideSetsConfigPath() async {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
.path
self.withEnv("CLAWDBOT_CONFIG_PATH", value: nil) {
self.withEnv("CLAWDBOT_STATE_DIR", value: dir) {
#expect(ClawdbotConfigFile.stateDirURL().path == dir)
#expect(ClawdbotConfigFile.url().path == "\(dir)/clawdbot.json")
}
await TestIsolation.withEnvValues([
"CLAWDBOT_CONFIG_PATH": nil,
"CLAWDBOT_STATE_DIR": dir,
]) {
#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
@testable import Clawdbot
@Suite(.serialized) struct GatewayEnvironmentTests {
@Suite struct GatewayEnvironmentTests {
@Test func semverParsesCommonForms() {
#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))
@@ -19,29 +19,19 @@ import Testing
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
}
@Test func gatewayPortDefaultsAndRespectsOverride() {
let envKey = "CLAWDBOT_CONFIG_PATH"
let previousEnv = getenv(envKey).map { String(cString: $0) }
let configPath = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
.path
setenv(envKey, configPath, 1)
defer {
if let previousEnv {
setenv(envKey, previousEnv, 1)
} else {
unsetenv(envKey)
}
@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)
}
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() {

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