import Foundation import Testing @testable import Kanban /// `FolderWatcher` is the one component here whose correctness is a *timing* claim — "one reload /// per burst", "nothing mid-bracket", "always one after the bracket" — so these tests are written /// against a real FSEvents stream on a real temp directory rather than against a fake. A fake /// would pin the debounce logic and prove nothing about the two things most likely to be wrong: /// the flags the stream actually sends and the paths it actually reports. /// /// That makes deadlines the main flakiness risk, and they are handled by asymmetry: **waiting for /// something is generous** (poll up to seconds — a slow machine must not fail a test), while /// **waiting for nothing is a fixed quiet period** well past the debounce. Assertions are never /// weakened to buy stability; the deadlines are. // MARK: - Support /// A temp directory per test, and the only place a test touches the filesystem. @MainActor private struct WatchFixture { let root: URL init(create: Bool = true) throws { root = FileManager.default.temporaryDirectory .appendingPathComponent("FolderWatcherTests-\(UUID().uuidString)", isDirectory: true) if create { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } } func tearDown() { try? FileManager.default.removeItem(at: root) } /// Writes a file directly — this is a *foreign* write by construction: no Writer, no bracket, /// exactly what an editor or an agent does. func write(_ relativePath: String, _ text: String = "x") { let fileURL = root.appendingPathComponent(relativePath) try? FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) try? Data(text.utf8).write(to: fileURL) } func makeDirectory(_ relativePath: String) { try? FileManager.default.createDirectory( at: root.appendingPathComponent(relativePath, isDirectory: true), withIntermediateDirectories: true ) } func remove() { try? FileManager.default.removeItem(at: root) } } /// Accumulates what the watcher delivered. Main-actor, like the handler. @MainActor private final class EventLog { private(set) var events: [WatcherEvent] = [] var count: Int { events.count } func record(_ event: WatcherEvent) { events.append(event) } func reset() { events.removeAll() } var origins: [WatchOrigin] { events.compactMap { if case .treeChanged(let origin) = $0 { origin } else { nil } } } } /// Polls `condition` until it holds or `deadline` elapses. Generous by default: FSEvents delivery /// is not a bounded-latency promise, and a busy CI machine can take seconds. @MainActor private func waitUntil(_ deadline: Duration = .seconds(5), _ condition: () -> Bool) async { let start = ContinuousClock.now while ContinuousClock.now - start < deadline { if condition() { return } try? await Task.sleep(for: .milliseconds(25)) } } /// A fixed quiet period — the shape every "and then *nothing* else happened" assertion takes. /// Comfortably past `testDebounce` plus `testLatency` plus FSEvents' own delivery slack. @MainActor private func quiet(_ duration: Duration = .milliseconds(500)) async { try? await Task.sleep(for: duration) } /// Short enough to keep the suite quick, long enough to coalesce a burst of writes on a slow /// machine. Deliberately larger than `testLatency` by an order of magnitude, so the debounce — /// not FSEvents — is what does the coalescing under test. private let testDebounce = Duration.milliseconds(100) private let testLatency = 0.02 /// Gives the freshly created stream a beat to register with `fseventsd` before a test writes. /// Without it, the first write of a test can land in the window between `FSEventStreamStart` and /// the stream actually being live — a real (if rare) source of "the first event never arrived". @MainActor private func settle() async { try? await Task.sleep(for: .milliseconds(300)) } /// Waits for the stream to go quiet, then throws away whatever arrived before that. /// /// **Observed, and the reason this helper exists**: `kFSEventStreamEventIdSinceNow` is not the /// clean line it reads as. Each test creates its temp directory milliseconds before creating the /// stream, and `fseventsd` assigns that `mkdir` an event id *after* the stream is already live — /// so a freshly started watcher reliably sees one `.foreign` delivery it did nothing to earn. /// This is an artefact of watching a directory that was created a moment ago, not a bug: in the /// app a stream is created over a board folder that has existed for a while, and a spurious /// reload on open would cost one value-equal snapshot swap anyway. /// /// Draining is a *wait for quiet*, not a fixed sleep, so a straggler cannot land just after the /// reset and pollute the test that follows. @MainActor private func drainStartupChurn( _ log: EventLog, quietFor: Duration = .milliseconds(400), deadline: Duration = .seconds(5) ) async { let start = ContinuousClock.now var lastCount = -1 var lastChange = ContinuousClock.now while ContinuousClock.now - start < deadline { if log.count != lastCount { lastCount = log.count lastChange = ContinuousClock.now } else if ContinuousClock.now - lastChange >= quietFor { break } try? await Task.sleep(for: .milliseconds(25)) } log.reset() } // MARK: - Tests @MainActor @Suite("FolderWatcher") struct FolderWatcherTests { // MARK: Ordinary foreign change @Test("A single external write delivers exactly one foreign tree change") func singleForeignWrite() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) #expect(watcher.isWatching) defer { watcher.stop() } await drainStartupChurn(log) fixture.write("card.md") await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.foreign)]) } @Test("A burst of writes coalesces into one delivery") func burstCoalesces() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) // Ten files back to back: the shape of an agent filing a batch of cards, or a `git // checkout` landing a branch's worth of changes. for index in 0..<10 { fixture.write("card-\(index).md", "body \(index)") } await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.foreign)]) } // MARK: Brackets @Test("An open bracket suppresses events; closing it delivers one app-mediated reload") func bracketSuppressesThenDelivers() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) watcher.beginBracket() for index in 0..<3 { fixture.write("bracketed-\(index).md") } // Well past debounce + latency: if a bracket leaked, this is where it would show. await quiet(.milliseconds(700)) #expect(log.events.isEmpty) watcher.endBracket() await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.appMediated)]) } @Test("Nested brackets deliver once, at the outermost close") func nestedBrackets() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) watcher.beginBracket() watcher.beginBracket() fixture.write("nested.md") await quiet(.milliseconds(400)) #expect(log.events.isEmpty) watcher.endBracket() await quiet(.milliseconds(400)) #expect(log.events.isEmpty, "the inner close is not a close — depth is still 1") watcher.endBracket() await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.appMediated)]) } @Test("Closing a bracket that saw no filesystem events still delivers the reload") func emptyBracketStillReloads() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) // The mandatory post-bracket reload: the bracket's contract is "finish with one full // reload", not "finish with one reload if something happened". watcher.beginBracket() watcher.endBracket() await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.appMediated)]) } // MARK: Reconciliation and origin merge @Test("reconcile() delivers a reconciling reload with no filesystem activity") func reconcileWithoutFilesystemActivity() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) watcher.reconcile() await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.reconciling)]) } @Test("A foreign change folding into a reconcile does not downgrade the origin") func originMergeKeepsTheStrongerClaim() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) fixture.write("foreign.md") // Inside the debounce window, so the two spans coalesce into one delivery: the merged // span is genuinely covered by a reconciling reload, and `reconciling` is the honest // label for it. watcher.reconcile() await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.reconciling)]) } // MARK: .git filtering @Test("Churn inside .git is ignored; ordinary files still arrive") func gitInternalChurnIsFiltered() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } fixture.makeDirectory(".git") let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) // What the app's own auto-commit produces, and what an external `git gc` produces: pure // history churn that cannot alter the rendered tree. fixture.write(".git/index", "fake index") fixture.write(".git/objects/ab/cdef", "fake object") fixture.write(".git/refs/heads/main", "deadbeef") await quiet(.milliseconds(700)) #expect(log.events.isEmpty) // …and the stream is demonstrably still alive, which is the other half of the claim: the // filter drops events, it does not stop the watcher. fixture.write("card.md") await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.foreign)]) } @Test("A repo nested inside a card folder is filtered like the root's own") func nestedGitChurnIsFiltered() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let cardFolder = "1e9a7c5e-0000-4000-8000-000000000000" fixture.makeDirectory("\(cardFolder)/.git") let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) // A clone living inside a card folder is a stray (01-storage-format.md) whose internals // never render — filtered at any depth, not just the board root's own repo (02, settled). fixture.write("\(cardFolder)/.git/index", "fake index") fixture.write("\(cardFolder)/.git/refs/heads/main", "deadbeef") await quiet(.milliseconds(700)) #expect(log.events.isEmpty) // The nested repo's *working files* are ordinary stray paths and still fire. fixture.write("\(cardFolder)/README.md") await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.foreign)]) } // MARK: .trash coverage /// **The trash is watched, unlike `.git`** (01-storage-format.md § Deletion, resettled /// 2026-07-28). It is the one hidden folder in a board whose contents are *rendered* — a /// materialized container of ordinary cards — so a foreign delete, restore or purge has to /// reload the board like any other move. The filter tests for a `.git` component specifically /// rather than for a dot prefix, and this is the test that keeps it that way. @Test("Changes inside .trash are delivered, unlike .git churn") func trashChangesAreDelivered() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } fixture.makeDirectory(".git") fixture.makeDirectory(".trash") let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) // What another device's (or an agent's) delete looks like from here: a card folder // appearing inside the container. let card = "2b7c9d10-0000-4000-8000-000000000000" fixture.write(".trash/\(card)/index.md", "---\nschema: 1\norder: -1024\n---\n") await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.foreign)]) // …and a purge of it is a change too, while `.git` in the same board stays filtered. log.reset() fixture.write(".git/index", "fake index") try? FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".trash/\(card)")) await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.foreign)]) } // MARK: Root identity @Test("Deleting the watched root delivers rootChanged and tears the stream down") func rootDeletionIsReported() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) fixture.remove() await waitUntil(.seconds(10)) { log.events.contains(.rootChanged) } #expect(log.events.contains(.rootChanged)) // The stream is gone with the root it was watching — recovery is a *fresh* stream via // `reattach(to:)`, never a resumed one. #expect(!watcher.isWatching) } @Test("A root vanish mid-bracket is owned by the root-change path; endBracket() is a no-op") func rootVanishMidBracketSkipsThePostBracketReload() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) watcher.beginBracket() fixture.remove() // `.rootChanged` bypasses the bracket — the consumer must know *now*. await waitUntil(.seconds(10)) { log.events.contains(.rootChanged) } #expect(log.events == [.rootChanged]) #expect(!watcher.isWatching) // The bracket's mandatory final reload is skipped: the stream is gone, and the // root-change path owns recovery from here (02, settled) — `reattach(to:)` or the // vanished-root lock, each ending in a reload of its own. watcher.endBracket() await quiet(.milliseconds(700)) #expect(log.events == [.rootChanged]) } // MARK: Stop @Test("Nothing is delivered after stop()") func stopIsFinal() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) await drainStartupChurn(log) watcher.stop() #expect(!watcher.isWatching) fixture.write("after-stop.md") await quiet(.milliseconds(800)) #expect(log.events.isEmpty) } @Test("stop() cancels a delivery that was already pending") func stopCancelsPendingDelivery() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: .milliseconds(600), latency: testLatency) { log.record($0) } #expect(watcher.start()) await drainStartupChurn(log) // Armed but not yet fired — the window where a debounce could outlive its watcher. watcher.reconcile() watcher.stop() await quiet(.milliseconds(900)) #expect(log.events.isEmpty) } // MARK: Reattach @Test("reattach() follows the root: one reconciling reload, then the new tree, never the old") func reattachFollowsTheRoot() async throws { let original = try WatchFixture() defer { original.tearDown() } let destination = try WatchFixture() defer { destination.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: original.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) // The rename-absorption path: the consumer re-resolved its bookmark to a new location. watcher.reattach(to: destination.root) await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.reconciling)]) #expect(watcher.isWatching) await drainStartupChurn(log) destination.write("moved-card.md") await waitUntil { log.count >= 1 } await quiet() #expect(log.events == [.treeChanged(.foreign)], "the new root is live") // The old location is no longer anyone's board. original.write("stale-card.md") await quiet(.milliseconds(800)) #expect(log.count == 1, "the old root is not watched by anything any more") } @Test("reattach() works after the root vanished from under the watcher") func reattachAfterRootChanged() async throws { let original = try WatchFixture() defer { original.tearDown() } let destination = try WatchFixture() defer { destination.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: original.root, debounce: testDebounce, latency: testLatency) { log.record($0) } #expect(watcher.start()) defer { watcher.stop() } await drainStartupChurn(log) original.remove() await waitUntil(.seconds(10)) { log.events.contains(.rootChanged) } #expect(!watcher.isWatching) // The stream is torn down, and a fresh one attaches cleanly — the "streams die and are // recreated, not merely kept" rule, exercised end to end. watcher.reattach(to: destination.root) #expect(watcher.isWatching) await waitUntil { log.origins.contains(.reconciling) } #expect(log.origins.contains(.reconciling)) await settle() let countAfterReattach = log.count destination.write("card.md") await waitUntil { log.count > countAfterReattach } #expect(log.count > countAfterReattach) } // MARK: Nonexistent root @Test("Starting on a path that does not exist is not an error, and the stream stays honest") func startOnNonexistentPath() async throws { let fixture = try WatchFixture(create: false) defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } // **Observed FSEvents behaviour, not an aspiration**: `FSEventStreamCreate` and // `FSEventStreamStart` both succeed for a path that does not exist — FSEvents watches a // path, not an inode, and is perfectly willing to watch one that is not there yet. So // `start()` returns `true` and `isWatching` is `true`: the honest answer, because the // stream really is live. Existence checking belongs to the caller (the open path already // does it); this type's contract is only that it never crashes and never lies about // whether it is watching. let started = watcher.start() #expect(started, "FSEvents watches a path, not an inode — a missing one is fine by it") #expect(watcher.isWatching == started) defer { watcher.stop() } await drainStartupChurn(log) // And the path *appearing* is itself a root change under `WatchRoot` — so a watcher // started early does not go deaf, it reports the identity change and hands the consumer // its ordinary re-resolve-and-`reattach(to:)` job. try FileManager.default.createDirectory(at: fixture.root, withIntermediateDirectories: true) await waitUntil(.seconds(10)) { !log.events.isEmpty } #expect(log.events == [.rootChanged]) #expect(!watcher.isWatching, "a root change tears the stream down, appearing or vanishing") } @Test("Every method is safe on a watcher whose stream was never started") func methodsAreSafeWithoutAStream() async throws { let fixture = try WatchFixture() defer { fixture.tearDown() } let log = EventLog() let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) { log.record($0) } // The degraded-but-alive contract: a failed `start()` must not turn every later call into // a crash. Brackets and reconciles still behave; they simply have no stream prompting // them. #expect(!watcher.isWatching) watcher.endBracket() // unbalanced: ignored, not a trap watcher.beginBracket() watcher.endBracket() await waitUntil { log.count >= 1 } #expect(log.events == [.treeChanged(.appMediated)]) watcher.stop() watcher.stop() #expect(!watcher.isWatching) } }