1import AppKit2import Foundation3import Testing45@testable import Haven67private final class ManagerURLProtocol: URLProtocol, @unchecked Sendable {8 override class func canInit(with request: URLRequest) -> Bool { true }9 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }10 override func startLoading() {11 let path = request.url!.path12 let statusCode = path.contains("fail-http") ? 500 : 20013 let data: Data14 if path.contains("image.png") {15 data = onePixelPNG16 } else if path.contains("invalid.png") {17 data = Data("not an image".utf8)18 } else {19 data = Data("wallpaper".utf8)20 }21 let response = HTTPURLResponse(22 url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!23 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)24 client?.urlProtocol(self, didLoad: data)25 client?.urlProtocolDidFinishLoading(self)26 }27 override func stopLoading() {}28}2930private func managerSession() -> URLSession {31 let configuration = URLSessionConfiguration.ephemeral32 configuration.protocolClasses = [ManagerURLProtocol.self]33 return URLSession(configuration: configuration)34}3536private let onePixelPNG = Data(37 base64Encoded:38 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="39)!4041@Suite("Favorites persistence", .serialized)42struct FavoritesManagerTests {43 private let defaultsKey = "favoriteWallpapers"4445 @Test("Toggling adds and removes a favorite")46 func toggleFavorite() {47 UserDefaults.standard.removeObject(forKey: defaultsKey)48 defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }49 let manager = FavoritesManager()50 let wallpaper = makeWallpaper()5152 manager.toggleFavorite(wallpaper)53 #expect(manager.favorites == [wallpaper])54 #expect(manager.isFavorite(wallpaper))5556 manager.toggleFavorite(wallpaper)57 #expect(manager.favorites.isEmpty)58 #expect(!manager.isFavorite(wallpaper))59 }6061 @Test("Favorites survive manager recreation")62 func favoritesPersist() {63 UserDefaults.standard.removeObject(forKey: defaultsKey)64 defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }65 let wallpaper = makeWallpaper()66 FavoritesManager().toggleFavorite(wallpaper)6768 let reloaded = FavoritesManager()6970 #expect(reloaded.favorites == [wallpaper])71 }72}7374@Suite("History persistence", .serialized)75struct HistoryManagerTests {76 private let defaultsKey = "wallpaperHistory"7778 @Test("History keeps newest items first and promotes duplicates")79 func orderingAndPromotion() {80 UserDefaults.standard.removeObject(forKey: defaultsKey)81 defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }82 let manager = HistoryManager()83 let first = makeWallpaper(id: "first")84 let second = makeWallpaper(id: "second")8586 manager.addToHistory(first)87 manager.addToHistory(second)88 manager.addToHistory(first)8990 #expect(manager.history.map(\.id) == ["first", "second"])91 }9293 @Test("History is capped at fifty entries")94 func historyLimit() {95 UserDefaults.standard.removeObject(forKey: defaultsKey)96 defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }97 let manager = HistoryManager()9899 for index in 0..<55 {100 manager.addToHistory(makeWallpaper(id: "item-\(index)"))101 }102103 #expect(manager.history.count == 50)104 #expect(manager.history.first?.id == "item-54")105 #expect(manager.history.last?.id == "item-5")106 }107108 @Test("History survives recreation and clear removes persisted data")109 func persistenceAndClear() {110 UserDefaults.standard.removeObject(forKey: defaultsKey)111 defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }112 HistoryManager().addToHistory(makeWallpaper(id: "persisted"))113114 let reloaded = HistoryManager()115 #expect(reloaded.history.map(\.id) == ["persisted"])116117 reloaded.clearHistory()118 #expect(HistoryManager().history.isEmpty)119 }120}121122@Suite("Image cache")123struct ImageCacheTests {124 @Test("Inserted images are returned for the same URL")125 func insertAndRetrieve() {126 let url = URL(string: "https://example.com/\(UUID().uuidString).png")!127 let image = NSImage(size: NSSize(width: 10, height: 20))128129 ImageCache.shared.insertImage(image, for: url)130131 #expect(ImageCache.shared.image(for: url) === image)132 }133134 @Test("Different URLs do not collide")135 func keysDoNotCollide() {136 let base = UUID().uuidString137 let firstURL = URL(string: "https://example.com/\(base)-first.png")!138 let secondURL = URL(string: "https://example.com/\(base)-second.png")!139140 ImageCache.shared.insertImage(NSImage(size: NSSize(width: 1, height: 1)), for: firstURL)141142 #expect(ImageCache.shared.image(for: secondURL) == nil)143 }144145 @Test("Fetch decodes and caches successful image responses")146 func fetchAndCache() async throws {147 let cache = ImageCache(session: managerSession())148 let url = URL(string: "https://example.com/image.png")!149150 let first = try await cache.fetchImage(url: url)151 let second = try await cache.fetchImage(url: url)152153 #expect(first === second)154 }155156 @Test("Fetch rejects HTTP errors and invalid image bytes")157 func fetchFailures() async {158 let cache = ImageCache(session: managerSession())159 let httpError = URL(string: "https://example.com/fail-http.png")!160 let invalid = URL(string: "https://example.com/invalid.png")!161 await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: httpError) }162 await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: invalid) }163 }164}165166@Suite("Wallpaper file management", .serialized)167@MainActor168struct WallpaperManagerTests {169 @Test("Download validates HTTP status and writes response data")170 func download() async throws {171 let manager = WallpaperManager(session: managerSession())172 let url = URL(string: "https://example.com/\(UUID().uuidString).jpg")!173 let downloaded = try await manager.download(from: url)174 defer { try? FileManager.default.removeItem(at: downloaded) }175176 #expect(try Data(contentsOf: downloaded) == Data("wallpaper".utf8))177178 let errorURL = URL(string: "https://example.com/fail-http.jpg")!179 await #expect(throws: WallhavenError.self) { try await manager.download(from: errorURL) }180 }181182 @Test("Desktop setter receives explicit and default screens")183 @MainActor184 func desktopSetter() throws {185 guard let screen = NSScreen.screens.first else { return }186 var received: [NSScreen] = []187 let manager = WallpaperManager(desktopSetter: { _, target in received.append(target) })188 let url = URL(fileURLWithPath: "/tmp/test-wallpaper.jpg")189190 try manager.setDesktopImage(from: url, screen: screen)191 try manager.setDesktopImage(from: url)192193 #expect(received.count == 2)194 #expect(received[0] === screen)195 }196197 @Test("Default desktop adapter reports invalid local files")198 func defaultDesktopAdapter() {199 guard let screen = NSScreen.screens.first else { return }200 let manager = WallpaperManager()201 #expect(throws: Error.self) {202 try manager.setDesktopImage(203 from: URL(fileURLWithPath: "/definitely-missing-haven-wallpaper.jpg"), screen: screen)204 }205 }206207 @Test("Set wallpaper composes download and desktop update")208 @MainActor209 func setWallpaper() async throws {210 guard let screen = NSScreen.screens.first else { return }211 var receivedURL: URL?212 let manager = WallpaperManager(213 session: managerSession(), desktopSetter: { url, _ in receivedURL = url })214 let remote = URL(string: "https://example.com/\(UUID().uuidString).jpg")!215216 try await manager.setWallpaper(from: remote, screen: screen)217 defer { if let receivedURL { try? FileManager.default.removeItem(at: receivedURL) } }218219 #expect(receivedURL?.lastPathComponent == remote.lastPathComponent)220 }221222 @Test("Saving creates the destination folder and copies the file")223 func saveCreatesFolderAndCopies() throws {224 let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)225 defer { try? FileManager.default.removeItem(at: root) }226 let source = root.appendingPathComponent("source/wallpaper.jpg")227 let destination = root.appendingPathComponent("nested/destination")228 try FileManager.default.createDirectory(229 at: source.deletingLastPathComponent(), withIntermediateDirectories: true)230 try Data("first".utf8).write(to: source)231232 try WallpaperManager.shared.saveToFolder(from: source, to: destination)233234 let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))235 #expect(copied == Data("first".utf8))236 }237238 @Test("Saving replaces an existing file with fresh data")239 func saveReplacesExistingFile() throws {240 let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)241 defer { try? FileManager.default.removeItem(at: root) }242 let source = root.appendingPathComponent("source/wallpaper.jpg")243 let destination = root.appendingPathComponent("destination")244 try FileManager.default.createDirectory(245 at: source.deletingLastPathComponent(), withIntermediateDirectories: true)246 try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)247 try Data("new".utf8).write(to: source)248 try Data("old".utf8).write(to: destination.appendingPathComponent("wallpaper.jpg"))249250 try WallpaperManager.shared.saveToFolder(from: source, to: destination)251252 let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))253 #expect(copied == Data("new".utf8))254 }255}256257@Suite("Autostart persistence", .serialized)258@MainActor259struct AutostartManagerTests {260 @Test("Enable writes a launch agent and disable removes it")261 func enableAndDisable() throws {262 let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)263 defer { try? FileManager.default.removeItem(at: root) }264 let manager = AutostartManager(265 libraryDirectory: root, executablePath: { "/Applications/Haven.app/Contents/MacOS/Haven" })266267 #expect(!manager.isEnabled)268 manager.isEnabled = true269 #expect(manager.isEnabled)270 manager.isEnabled = true271272 let plist = root.appendingPathComponent("LaunchAgents/com.june.Haven.plist")273 let contents =274 try PropertyListSerialization.propertyList(275 from: Data(contentsOf: plist), format: nil) as? [String: Any]276 #expect(contents?["Label"] as? String == "com.june.Haven")277 #expect(278 contents?["ProgramArguments"] as? [String]279 == ["/Applications/Haven.app/Contents/MacOS/Haven"])280 #expect(contents?["RunAtLoad"] as? Bool == true)281282 manager.isEnabled = false283 #expect(!manager.isEnabled)284 manager.isEnabled = false285 }286287 @Test("Missing executable leaves autostart disabled")288 func missingExecutable() {289 let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)290 defer { try? FileManager.default.removeItem(at: root) }291 let manager = AutostartManager(libraryDirectory: root, executablePath: { nil })292293 manager.isEnabled = true294295 #expect(!manager.isEnabled)296 }297298 @Test("Default executable provider writes only inside the injected library")299 func defaultExecutableProvider() {300 let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)301 defer { try? FileManager.default.removeItem(at: root) }302 let manager = AutostartManager(libraryDirectory: root)303 manager.isEnabled = true304 #expect(manager.isEnabled)305 manager.isEnabled = false306 }307308 @Test("Unwritable launch-agent roots do not enable autostart")309 func writeFailure() throws {310 let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)311 defer { try? FileManager.default.removeItem(at: file) }312 try Data("not a directory".utf8).write(to: file)313 let manager = AutostartManager(libraryDirectory: file, executablePath: { "/tmp/Haven" })314 manager.isEnabled = true315 #expect(!manager.isEnabled)316 }317318 @Test("Removal failures leave the launch agent enabled")319 func removalFailure() {320 let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)321 defer { try? FileManager.default.removeItem(at: root) }322 let manager = AutostartManager(323 libraryDirectory: root, executablePath: { "/tmp/Haven" },324 removePlist: { _ in throw CocoaError(.fileWriteNoPermission) })325 manager.isEnabled = true326 manager.isEnabled = false327 #expect(manager.isEnabled)328 }329}330331@Suite("Local wallpaper library", .serialized)332@MainActor333struct LocalWallpapersManagerTests {334 private func waitForScan(_ manager: LocalWallpapersManager) async {335 for _ in 0..<100 where manager.isLoading {336 try? await Task.sleep(nanoseconds: 10_000_000)337 }338 }339340 @Test("Scan filters files, reads metadata, and deletes wallpapers")341 func scanAndDelete() async throws {342 let defaults = UserDefaults.standard343 let oldPath = defaults.string(forKey: "saveFolderPath")344 defaults.removeObject(forKey: "saveFolderPath")345 defer {346 if let oldPath {347 defaults.set(oldPath, forKey: "saveFolderPath")348 } else {349 defaults.removeObject(forKey: "saveFolderPath")350 }351 }352 let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)353 defer { try? FileManager.default.removeItem(at: root) }354 try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)355 try onePixelPNG.write(to: root.appendingPathComponent("wallpaper.png"))356 try Data("broken".utf8).write(to: root.appendingPathComponent("broken.png"))357 try Data("ignored".utf8).write(to: root.appendingPathComponent("notes.txt"))358 let manager = LocalWallpapersManager()359360 manager.scanFolder(path: root.path)361 manager.scanFolder(path: root.path)362 await waitForScan(manager)363364 let wallpaper = try #require(manager.wallpapers.first)365 #expect(manager.wallpapers.count == 1)366 #expect(wallpaper.thumbnailURL == wallpaper.url)367 #expect(wallpaper.filename == "wallpaper.png")368 #expect(wallpaper.resolution == "1x1")369 #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("broken.png")) == nil)370 #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("missing.png")) == nil)371372 manager.deleteWallpaper(wallpaper)373 #expect(manager.wallpapers.isEmpty)374 manager.deleteWallpaper(wallpaper)375 #expect(manager.errorMessage?.hasPrefix("Failed to delete") == true)376 }377378 @Test("Invalid scan roots surface an error")379 func scanError() async throws {380 let defaults = UserDefaults.standard381 defaults.removeObject(forKey: "saveFolderPath")382 let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)383 defer { try? FileManager.default.removeItem(at: file) }384 try Data("file".utf8).write(to: file)385 let manager = LocalWallpapersManager()386387 manager.scanFolder(path: file.path)388 await waitForScan(manager)389390 #expect(manager.errorMessage?.hasPrefix("Failed to scan folder") == true)391 #expect(!manager.isLoading)392 }393394 @Test("Monitoring configured folders observes filesystem changes and stops")395 func monitoring() async throws {396 let defaults = UserDefaults.standard397 let oldPath = defaults.string(forKey: "saveFolderPath")398 let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)399 try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)400 defaults.set(root.path, forKey: "saveFolderPath")401 defer {402 try? FileManager.default.removeItem(at: root)403 if let oldPath {404 defaults.set(oldPath, forKey: "saveFolderPath")405 } else {406 defaults.removeObject(forKey: "saveFolderPath")407 }408 }409 let manager = LocalWallpapersManager()410 await waitForScan(manager)411412 try onePixelPNG.write(to: root.appendingPathComponent("observed.png"))413 try? await Task.sleep(nanoseconds: 100_000_000)414 await waitForScan(manager)415 #expect(manager.wallpapers.contains(where: { $0.filename == "observed.png" }))416417 manager.stopMonitoring()418 try? await Task.sleep(nanoseconds: 500_000_000)419420 let invalidMonitor = FolderMonitor(folderPath: root.appendingPathComponent("missing").path) {}421 invalidMonitor.start()422 invalidMonitor.closeFileDescriptor()423424 let descriptor = open("/dev/null", O_RDONLY)425 let descriptorMonitor = FolderMonitor(426 folderPath: "/dev/null", fileDescriptor: descriptor, callback: {})427 descriptorMonitor.closeFileDescriptor()428 #expect(fcntl(descriptor, F_GETFD) == -1)429 }430}