test(macos): isolate env + defaults
This commit is contained in:
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
104
apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
Normal file
104
apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user