From 7cd15d3bb383f40e6e0a66bc88e6df1b6fb26a2e Mon Sep 17 00:00:00 2001 From: Dan Amouyal Date: Sun, 6 Sep 2026 21:25:42 +0300 Subject: [PATCH 1/2] test(reclaim): cover the code that deletes Reclaimer is the only place in the app that removes anything, and it was the only service without a test. These pin the behaviour the UI promises: emptyDirectories keeps the directory itself, prune keeps files newer than the cutoff and removes only the directories it emptied, steps stops at the first failure instead of carrying on down the list, and a delete of a path the user already removed by hand is a no-op rather than an error that would stop a batch halfway. Everything happens inside a temporary directory the suite creates and removes; the whole suite runs in 6 ms. --- Tests/SysDataMenuTests/ReclaimerTests.swift | 138 ++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 Tests/SysDataMenuTests/ReclaimerTests.swift diff --git a/Tests/SysDataMenuTests/ReclaimerTests.swift b/Tests/SysDataMenuTests/ReclaimerTests.swift new file mode 100644 index 0000000..aa379ed --- /dev/null +++ b/Tests/SysDataMenuTests/ReclaimerTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Testing +@testable import SysDataMenu + +/// `Reclaimer` is the only code in the app that deletes anything, so its +/// behaviour is pinned here against a scratch tree. Nothing outside the +/// temporary directory this suite creates is ever touched. +@Suite struct ReclaimerTests { + /// A disposable directory tree, removed when the test ends. + private struct Scratch: ~Copyable { + let root: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appending(path: "sysdata-reclaim-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + @discardableResult + func file(_ name: String, bytes: Int = 32, modified: Date? = nil) throws -> URL { + let url = root.appending(path: name) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try Data(count: bytes).write(to: url) + if let modified { + try FileManager.default.setAttributes([.modificationDate: modified], ofItemAtPath: url.path) + } + return url + } + + func url(_ name: String) -> URL { root.appending(path: name) } + + deinit { try? FileManager.default.removeItem(at: root) } + } + + @Test func removePathsDeletesFilesAndDirectories() async throws { + let scratch = try Scratch() + let file = try scratch.file("loose.bin") + try scratch.file("tree/nested/deep.bin") + let tree = scratch.url("tree") + + try await Reclaimer.perform(.removePaths([file, tree])) + + #expect(!file.exists) + #expect(!tree.exists) + } + + @Test func removePathsIgnoresPathsThatAreAlreadyGone() async throws { + let scratch = try Scratch() + let missing = scratch.url("never-existed") + + // A second delete of an item the user removed by hand must not throw, + // otherwise a batch stops halfway on a no-op. + try await Reclaimer.perform(.removePaths([missing])) + } + + @Test func emptyDirectoriesClearsContentsButKeepsTheDirectory() async throws { + let scratch = try Scratch() + try scratch.file("cache/a.bin") + try scratch.file("cache/.hidden") + try scratch.file("cache/sub/b.bin") + let cache = scratch.url("cache") + + try await Reclaimer.perform(.emptyDirectories([cache])) + + #expect(cache.exists, "the directory itself must survive; tools expect it to be there") + #expect(cache.children(includeHidden: true).isEmpty, "hidden entries must go too") + } + + @Test func pruneRemovesOldFilesAndKeepsRecentOnes() async throws { + let scratch = try Scratch() + let old = try scratch.file("logs/old.log", modified: .now.addingTimeInterval(-40 * 86_400)) + let recent = try scratch.file("logs/recent.log", modified: .now.addingTimeInterval(-2 * 86_400)) + + try await Reclaimer.perform(.pruneOlderThan(scratch.url("logs"), days: 30)) + + #expect(!old.exists) + #expect(recent.exists) + } + + @Test func pruneRemovesDirectoriesLeftEmptyButKeepsPopulatedOnes() async throws { + let scratch = try Scratch() + try scratch.file("logs/stale/only.log", modified: .now.addingTimeInterval(-40 * 86_400)) + try scratch.file("logs/live/kept.log", modified: .now) + + try await Reclaimer.perform(.pruneOlderThan(scratch.url("logs"), days: 30)) + + #expect(!scratch.url("logs/stale").exists) + #expect(scratch.url("logs/live").exists) + } + + @Test func stepsStopAtTheFirstFailure() async throws { + let scratch = try Scratch() + let second = try scratch.file("second.bin") + + await #expect(throws: CommandError.self) { + try await Reclaimer.perform(.steps([ + .command(executable: "/usr/bin/false", arguments: []), + .removePaths([second]), + ])) + } + + #expect(second.exists, "a step after a failed one must not run") + } + + @Test func stepsRunInOrderWhenNothingFails() async throws { + let scratch = try Scratch() + let file = try scratch.file("box/thing.bin") + let box = scratch.url("box") + + try await Reclaimer.perform(.steps([ + .command(executable: "/usr/bin/true", arguments: []), + .removePaths([file]), + ])) + + #expect(!file.exists) + #expect(box.exists) + } + + @Test func failingCommandReportsTheCommandItRan() async throws { + let error = await #expect(throws: CommandError.self) { + try await Reclaimer.perform(.command(executable: "/bin/sh", arguments: ["-c", "exit 3"])) + } + + #expect(error?.result.status == 3) + #expect(error?.command.contains("/bin/sh") == true) + } + + @Test func manualActionDoesNothing() async throws { + let scratch = try Scratch() + let file = try scratch.file("kept.bin") + + try await Reclaimer.perform(.manual("Open System Settings")) + + #expect(file.exists) + } +} From 51d1cd00eabf5556fb2108bb41a403ce0d6f8fec Mon Sep 17 00:00:00 2001 From: Dan Amouyal Date: Sun, 6 Sep 2026 21:26:27 +0300 Subject: [PATCH 2/2] test(scan): make the machine-wide scan opt-in itemsAreWellFormed and jsonInventoryIsWellFormed each walk $HOME, /Library, /private/var, /opt, /usr/local and /Users/Shared and measure every file under them. On a developer Mac that is over 20 minutes for the pair, with no output until they finish, so `swift test` is not something anyone runs before a commit. Put them behind SYSDATA_SCAN_TESTS and give them a time limit. The remaining suite is 9 tests in 8 ms; the inventory is still one variable away, and a CI runner starts empty enough to enable it there. --- Tests/SysDataMenuTests/ProbeTests.swift | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Tests/SysDataMenuTests/ProbeTests.swift b/Tests/SysDataMenuTests/ProbeTests.swift index bce3b37..004c364 100644 --- a/Tests/SysDataMenuTests/ProbeTests.swift +++ b/Tests/SysDataMenuTests/ProbeTests.swift @@ -2,12 +2,27 @@ import Foundation import Testing @testable import SysDataMenu +/// The scan measures whatever the machine happens to hold, so a full run costs +/// minutes on a developer Mac (20+ with Xcode, simulators and node_modules +/// around) and its result changes with what is installed. That is worth having, +/// but not on every `swift test`, so the two tests that walk the disk are +/// opt-in: +/// +/// SYSDATA_SCAN_TESTS=1 swift test +/// +/// CI enables them, where the runner starts nearly empty and the walk is quick. +/// Everything else here runs in milliseconds. +enum MachineScan { + static let isEnabled = ProcessInfo.processInfo.environment["SYSDATA_SCAN_TESTS"] != nil +} + /// Every probe runs against the real machine, so only invariants are checked: /// probes must not throw or hang, and what they return must be well-formed. @Suite struct ProbeTests { private let probes: [(String, any StorageProbe)] = ProbeRegistry.all.map { (String(describing: type(of: $0)), $0) } - @Test func itemsAreWellFormed() async { + @Test(.enabled(if: MachineScan.isEnabled), .timeLimit(.minutes(30))) + func itemsAreWellFormed() async { var seen: Set = [] var all: [StorageItem] = [] for (label, probe) in probes { @@ -85,7 +100,8 @@ import Testing return english.first { $0.value == title }?.key ?? title } - @Test func jsonInventoryIsWellFormed() async throws { + @Test(.enabled(if: MachineScan.isEnabled), .timeLimit(.minutes(30))) + func jsonInventoryIsWellFormed() async throws { let pipe = Pipe() await JSONInventory.write(to: pipe.fileHandleForWriting) try pipe.fileHandleForWriting.close()