From bb90c31dc26164b95e23e6bb08e2dcf5cc0d22ca Mon Sep 17 00:00:00 2001 From: Alex Mabe Date: Fri, 15 May 2026 18:43:36 -0400 Subject: [PATCH] Fix hanging and failing tests in SwiftBarTests scheme The test scheme hosts SwiftBarTests.xctest inside SwiftBar.app, so AppDelegate.applicationDidFinishLaunching runs in the test process. Three things kept it running long after the last @Test completed and prevented the host from terminating: - Sparkle's SPUUpdater registers persistent runloop sources for update checks. - The missing-plugin-folder branch shows a modal NSAlert that the test runner cannot dismiss. - PluginManager.loadPlugins() spawns plugin processes, file-system observers, and timers. Detect the XCTest environment via XCTestConfigurationFilePath and skip the heavy setup, keeping only the lightweight pluginManager assignment that one integration test still references via delegate.pluginManager. Test runtime drops from "indefinite hang" to ~4 seconds. Also fix testSyncFilePlugins_keepsSymlinkedPackagedPluginMatchedByBundlePath which was a pre-existing failure: contentsOfDirectory(at:) does not follow a directory-typed URL when the URL is itself a symlink (returns ENOTDIR). Resolve the symlink inside PackagedPlugin.findMainExecutable before enumerating, while keeping the `.swiftbar` extension check on the unresolved URL. In packagedPluginFileState pass the unresolved packageURL to findMainExecutable for the same reason. --- SwiftBar/AppDelegate.swift | 22 ++++++++++++++++++---- SwiftBar/Plugin/PackagedPlugin.swift | 6 +++++- SwiftBar/Plugin/PluginManger.swift | 6 +++++- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/SwiftBar/AppDelegate.swift b/SwiftBar/AppDelegate.swift index 4432f22..04c946c 100644 --- a/SwiftBar/AppDelegate.swift +++ b/SwiftBar/AppDelegate.swift @@ -80,14 +80,30 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUStandardUserDriverDelegat var softwareUpdater: SPUUpdater! #endif + /// True when SwiftBar is hosting an XCTest / Swift Testing bundle. + /// Set by the test runner via the `XCTestConfigurationFilePath` env var. + static var isRunningTests: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil || + NSClassFromString("XCTestCase") != nil + } + func applicationDidFinishLaunching(_: Notification) { + // Wire up the plugin manager so tests that reference + // `delegate.pluginManager` have a usable instance, then bail out before + // touching anything that keeps the runloop alive (Sparkle, the + // missing-folder modal, file-system observers). Without this the test + // host never reaches application termination and the test process hangs + // indefinitely after the last @Test finishes. + pluginManager = PluginManager.shared + if Self.isRunningTests { return } + preferencesWindowController.window?.delegate = self setupToolbar() - + // Clean up any corrupted NSStatusItem visibility states from UserDefaults // This fixes issues where menubar items disappear after initial setup cleanupStatusItemVisibility() - + let hostBundle = Bundle.main #if !MAC_APP_STORE let updateDriver = SPUStandardUserDriver(hostBundle: hostBundle, delegate: self) @@ -109,8 +125,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUStandardUserDriverDelegat prefs.pluginDirectoryPath = nil } - // Instance of Plugin Manager must be created after app launch - pluginManager = PluginManager.shared pluginManager.loadPlugins() pluginManager.persistLatestSystemReport(reason: "application-did-finish-launching") diff --git a/SwiftBar/Plugin/PackagedPlugin.swift b/SwiftBar/Plugin/PackagedPlugin.swift index 668368c..6a00a1b 100644 --- a/SwiftBar/Plugin/PackagedPlugin.swift +++ b/SwiftBar/Plugin/PackagedPlugin.swift @@ -108,10 +108,14 @@ class PackagedPlugin: TimerArmingPlugin { return nil } + // contentsOfDirectory(at:) does not follow a directory-typed URL that is + // a symlink (it returns ENOTDIR), so resolve the link before enumerating. + // The `.swiftbar` extension check above already used the unresolved URL. let fileManager = FileManager.default + let enumerationURL = directory.resolvingSymlinksInPath() let contents: [URL] do { - contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + contents = try fileManager.contentsOfDirectory(at: enumerationURL, includingPropertiesForKeys: nil) } catch { os_log("Failed to read packaged plugin directory %{public}@: %{public}@", log: Log.plugin, type: .error, directory.path, error.localizedDescription) diff --git a/SwiftBar/Plugin/PluginManger.swift b/SwiftBar/Plugin/PluginManger.swift index 8b4bd29..f19a71e 100644 --- a/SwiftBar/Plugin/PluginManger.swift +++ b/SwiftBar/Plugin/PluginManger.swift @@ -57,8 +57,12 @@ func pluginSyncPath(for plugin: Plugin) -> String { } private func packagedPluginFileState(for packageURL: URL, fileManager: FileManager = .default) -> PluginFileState? { + // `findMainExecutable` requires the `.swiftbar` extension, which only the + // unresolved URL is guaranteed to have when the package is reached through + // a symlink. The enumerator still needs the resolved path so we can read + // file attributes off the real directory. let resolvedPackageURL = packageURL.resolvingSymlinksInPath() - guard PackagedPlugin.findMainExecutable(in: resolvedPackageURL) != nil, + guard PackagedPlugin.findMainExecutable(in: packageURL) != nil, let enumerator = fileManager.enumerator(at: resolvedPackageURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { return nil