diff --git a/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift b/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift index b4d037018..29f9e7251 100644 --- a/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift +++ b/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift @@ -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") } diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift index 2a5c70d60..9ee97e22c 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift @@ -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() - } } diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift index 00ce5ab1d..20d5b5973 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift @@ -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() { diff --git a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift new file mode 100644 index 000000000..fa0180131 --- /dev/null +++ b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift @@ -0,0 +1,104 @@ +import Foundation + +actor TestIsolationLock { + static let shared = TestIsolationLock() + + private var locked = false + private var waiters: [CheckedContinuation] = [] + + 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(_ body: () async throws -> T) async rethrows -> T { + await self.lock() + defer { self.unlock() } + return try await body() + } +} + +enum TestIsolation { + static func withIsolatedState( + 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( + _ values: [String: String?], + _ body: () async throws -> T) async rethrows -> T + { + try await Self.withIsolatedState(env: values, defaults: [:], body) + } + + static func withUserDefaultsValues( + _ 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 + } +}