From 3c9db782a55142c5e2ffd582b87f68aa2d96f24e Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 01:27:29 -0400 Subject: [PATCH] Keep Mac unit-test host away from mounted drives --- BitMatch/App/BitMatchApp.swift | 9 ++++ BitMatch/App/MacAppEnvironment.swift | 50 +++++++++++++++++++ .../Services/MacRemoteBackupController.swift | 3 ++ .../Core/Services/VolumeMonitorService.swift | 17 +++++++ BitMatchTests/MacAppEnvironmentTests.swift | 24 +++++++++ 5 files changed, 103 insertions(+) diff --git a/BitMatch/App/BitMatchApp.swift b/BitMatch/App/BitMatchApp.swift index ff67432b..376b57fb 100644 --- a/BitMatch/App/BitMatchApp.swift +++ b/BitMatch/App/BitMatchApp.swift @@ -60,7 +60,13 @@ struct BitMatchApp: App { init() { SharedLogger.transferEvent(.appStarted, run: nil) + #if DEBUG + let isUnitTestHost = MacUnitTestHost.isRunning() + if isUnitTestHost { MacUnitTestHost.clearState() } + let environment = isUnitTestHost ? MacAppEnvironment.makeForUnitTestHost() : MacAppEnvironment.make() + #else let environment = MacAppEnvironment.make() + #endif _environment = StateObject(wrappedValue: environment) _updater = StateObject(wrappedValue: UpdaterController(coordinator: environment.coordinator)) appDelegate.coordinator = environment.coordinator @@ -291,6 +297,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func applicationWillTerminate(_ notification: Notification) { SharedLogger.transferEvent(.appEnded, run: nil) + #if DEBUG + if MacUnitTestHost.isRunning() { MacUnitTestHost.clearState() } + #endif } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { diff --git a/BitMatch/App/MacAppEnvironment.swift b/BitMatch/App/MacAppEnvironment.swift index 73dd264f..ae79268b 100644 --- a/BitMatch/App/MacAppEnvironment.swift +++ b/BitMatch/App/MacAppEnvironment.swift @@ -5,8 +5,41 @@ // copy of its state: SFTP off-site backups (the thesis's named Mac // exception), volume access and backup-drive discovery, and choosing a // detected camera card as the source. +import Foundation import SwiftUI +#if DEBUG +/// App-hosted unit tests launch BitMatch.app before their own fixtures exist. +/// Keep that host away from the user's drives and saved transfer state. This +/// predicate is compiled out of Release builds. +enum MacUnitTestHost { + static func isRunning(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + environment["XCTestConfigurationFilePath"] != nil + || environment["XCTestBundlePath"] != nil + } + + // Parallel XCTest app hosts have different process IDs. Reuse these + // locations within one host and clear them at launch and normal exit. + static var defaultsSuiteName: String { + "BitMatch.UnitTestHost.\(ProcessInfo.processInfo.processIdentifier)" + } + + private static var scratchDirectory: URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("BitMatchUnitTestHost-\(ProcessInfo.processInfo.processIdentifier)", isDirectory: true) + } + + static var journalURL: URL { + scratchDirectory.appendingPathComponent("history.json") + } + + static func clearState() { + UserDefaults(suiteName: defaultsSuiteName)?.removePersistentDomain(forName: defaultsSuiteName) + try? FileManager.default.removeItem(at: scratchDirectory) + } +} +#endif + @MainActor final class MacAppEnvironment: ObservableObject { let coordinator: SharedAppCoordinator @@ -65,6 +98,23 @@ final class MacAppEnvironment: ObservableObject { monitorsVolumes: false ) } + + #if DEBUG + /// The XCTest app host needs a window, but no live drive discovery, + /// persisted projects, transfer history or remote queue scheduler. + static func makeForUnitTestHost() -> MacAppEnvironment { + let persistence = BitMatchPersistenceController(inMemory: true) + let store = CoreDataPhotographerJobStore(persistence: persistence) + let defaults = UserDefaults(suiteName: MacUnitTestHost.defaultsSuiteName)! + let coordinator = SharedAppCoordinator( + platformManager: MacOSPlatformManager.shared, + transferJournal: LocalTransferJournal(fileURL: MacUnitTestHost.journalURL), + photographerJobViewModel: PhotographerJobViewModel(store: store), + defaults: defaults + ) + return makeForTesting(coordinator: coordinator) + } + #endif } extension View { diff --git a/BitMatch/Core/Services/MacRemoteBackupController.swift b/BitMatch/Core/Services/MacRemoteBackupController.swift index f39d2336..7155076c 100644 --- a/BitMatch/Core/Services/MacRemoteBackupController.swift +++ b/BitMatch/Core/Services/MacRemoteBackupController.swift @@ -19,6 +19,9 @@ final class MacRemoteBackupController: ObservableObject { let photographerJobViewModel: PhotographerJobViewModel private let results: () -> [ResultRow] private var remoteBackupQueue: RemoteBackupQueue? + #if DEBUG + var hasRemoteQueueForTests: Bool { remoteBackupQueue != nil } + #endif private var remoteBackupTimer: Timer? private var remoteSchedulerIsRunning = false private var remoteSchedulerNeedsRun = false diff --git a/BitMatch/Core/Services/VolumeMonitorService.swift b/BitMatch/Core/Services/VolumeMonitorService.swift index 7d0e0827..2b8da17f 100644 --- a/BitMatch/Core/Services/VolumeMonitorService.swift +++ b/BitMatch/Core/Services/VolumeMonitorService.swift @@ -88,14 +88,28 @@ final class VolumeMonitorService: ObservableObject { // A singleton: it lives as long as the app, so it never tears monitoring // down, and the Disk Arbitration context below never dangles. private init() { + #if DEBUG + // Test models and MacSetupLocations reference this singleton even + // when their own monitoring is off. The app-hosted unit-test process + // must not enumerate real mounted media before a fixture is set up. + guard !MacUnitTestHost.isRunning() else { return } + #endif startMonitoring() // Initial scan of existing volumes scanExistingVolumes() } + + #if DEBUG + private(set) var startedLiveMonitoring = false + private(set) var scannedExistingVolumes = false + #endif // MARK: - Volume Monitoring private func startMonitoring() { + #if DEBUG + startedLiveMonitoring = true + #endif #if os(macOS) startDiskArbitrationMonitoring() startFileSystemMonitoring() @@ -286,6 +300,9 @@ final class VolumeMonitorService: ObservableObject { } private func scanExistingVolumes() { + #if DEBUG + scannedExistingVolumes = true + #endif vlog("🔍 Scanning existing volumes...") guard let volumes = Self.mountedVolumes() else { return } vlog("📂 Found \(volumes.count) volumes: \(volumes.map { $0.lastPathComponent })") diff --git a/BitMatchTests/MacAppEnvironmentTests.swift b/BitMatchTests/MacAppEnvironmentTests.swift index 2cb81d3b..0f3c69cd 100644 --- a/BitMatchTests/MacAppEnvironmentTests.swift +++ b/BitMatchTests/MacAppEnvironmentTests.swift @@ -6,6 +6,30 @@ import Testing /// job view model; none keeps its own copy. @MainActor struct MacAppEnvironmentTests { + #if DEBUG + @Test func unitTestHostDoesNotStartLiveDriveMonitoring() { + #expect(MacUnitTestHost.isRunning()) + #expect(MacUnitTestHost.isRunning(environment: ["XCTestConfigurationFilePath": "/tmp/test.xctest"])) + #expect(MacUnitTestHost.isRunning(environment: ["XCTestBundlePath": "/tmp/BitMatchTests.xctest"])) + #expect(!MacUnitTestHost.isRunning(environment: [:])) + + // The real singleton is reached by test models and the Mac setup + // view. Its initializer must not scan /Volumes in this process. + #expect(!VolumeMonitorService.shared.startedLiveMonitoring) + #expect(!VolumeMonitorService.shared.scannedExistingVolumes) + } + + @Test func unitTestHostKeepsStateOutOfTheUsersStores() { + let environment = MacAppEnvironment.makeForUnitTestHost() + let coordinator = environment.coordinator + #expect(coordinator.defaults !== UserDefaults.standard) + #expect(coordinator.transferJournal.fileURL == MacUnitTestHost.journalURL) + #expect(!environment.remoteBackups.hasRemoteQueueForTests) + #expect(!VolumeMonitorService.shared.startedLiveMonitoring) + #expect(!VolumeMonitorService.shared.scannedExistingVolumes) + } + #endif + private func makeEnvironment() throws -> (MacAppEnvironment, CoordinatorFolders) { let folders = try CoordinatorFolders() let coordinator = SharedAppCoordinator(