import Foundation import Testing @testable import Kanban /// What happens to an open board when its folder moves, disappears, or comes back — the settled /// root-identity rules of 02-architecture.md § Write-failure surfacing, exercised end to end /// against a **real FSEvents stream, a real registry, and a real bookmark**. /// /// A fake would prove nothing here. The three claims under test are all claims about the /// filesystem's actual behaviour: that a bookmark follows a rename, that FSEvents reports the /// *creation* of a path it was watching before that path existed, and that a `access(2)` probe is /// what distinguishes "this board loads fine" from "this board can be written to". Every one of /// them would be assumed rather than tested against a double. /// /// The flakiness that buys is handled the way `FolderWatcherTests` handles it: **waiting for /// something is generous** (poll for seconds — a slow machine must not fail a correctness test) /// and nothing is asserted from an elapsed interval. The one test that needs no filesystem timing /// at all — the writability clearing rule — drives the store's inbound door directly instead, so /// its ordering is exact rather than merely likely. // MARK: - Support /// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. /// A small board: two lanes, one card. Enough that a reload landing at a new root has something /// recognisable in it. @MainActor private func makeBoard(at root: URL) throws { let manager = FileManager.default try manager.createDirectory(at: root, withIntermediateDirectories: true) func write(_ relativePath: String, _ text: String) throws { let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true) try manager.createDirectory(at: folder, withIntermediateDirectories: true) try Data(text.utf8).write(to: folder.appendingPathComponent("index.md")) } try write("", Item.board) try write(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try write("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try write(Ident.lane2, Item.rich(order: "2048", title: "Doing")) } @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try makeBoard(at: fixture.root) return fixture } /// Adds a card by hand — a *foreign* write by construction: no Writer, no bracket, exactly what an /// agent or an editor does. private func writeCard(inBoard root: URL, lane: String, id: String, title: String, order: String) throws { let folder = root.appendingPathComponent(lane, isDirectory: true).appendingPathComponent(id, isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) try Data(Item.rich(order: order, title: title).utf8).write(to: folder.appendingPathComponent("index.md")) } private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] { (snapshot.lanes.first { $0.id.rawValue == id }?.cards ?? []).compactMap(\.title.value) } /// The one shape a path comparison may take here. A bookmark resolves to the canonical location /// (`/private/var/...`) while `FileManager.temporaryDirectory` hands out the symlinked one /// (`/var/...`), so raw `URL` equality would fail on a board that relocated perfectly. private func canonical(_ url: URL) -> String { url.resolvingSymlinksInPath().standardizedFileURL.path } /// Polls until `condition` holds or the deadline passes — generous, because FSEvents delivery is /// not a bounded-latency promise. A root change in particular can take several seconds to surface. @MainActor private func waitUntil(_ deadline: Duration = .seconds(15), _ condition: () -> Bool) async { let start = ContinuousClock.now while ContinuousClock.now - start < deadline { if condition() { return } try? await Task.sleep(for: .milliseconds(25)) } } /// Gives a freshly started stream a beat to register with `fseventsd`, so the first change a test /// makes cannot land in the window between `FSEventStreamStart` and the stream actually being live. @MainActor private func settle() async { try? await Task.sleep(for: .milliseconds(400)) } // MARK: - Tests @MainActor @Suite("Root recovery") struct RootRecoveryTests { // MARK: Rename absorption @Test("A rename is absorbed transparently: new root, no banner, no lock, wiring intact") func renameIsAbsorbed() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let renamed = fixture.root .deletingLastPathComponent() .appendingPathComponent("renamed-\(UUID().uuidString)", isDirectory: true) // Registered first so it runs *last*: the store is released — and its watcher stopped — // before the folder it is watching is removed. defer { try? FileManager.default.removeItem(at: renamed) } let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) defer { registry.release(store) } await settle() // A Finder rename, which 01-storage-format.md calls ordinary. The board is the *file*, not // the string that names it. try FileManager.default.moveItem(at: fixture.root, to: renamed) // The done-when: the store's URLs re-derived, and a reload actually ran at the new root // (`snapshot.rootURL` is the root the last successful walk used). await waitUntil { canonical(store.snapshot.rootURL) == canonical(renamed) } #expect(canonical(store.rootURL) == canonical(renamed)) #expect(canonical(store.snapshot.rootURL) == canonical(renamed)) // Nothing was ever wrong, and the strip says so. #expect(store.readOnlyLock == nil) #expect(store.reloadFailure == nil) #expect(store.bannerRows.isEmpty) #expect(store.snapshot.lanes.count == 2, "the board is still the board") // The entry followed too: identity did not change, so the same store answers for the new // path and a window opening it would share rather than duplicate. #expect(registry.liveStore(for: renamed) === store) #expect(registry.openBoardCount == 1) // And the wiring survived the move: a foreign edit at the *new* path reloads. try writeCard(inBoard: renamed, lane: Ident.lane1, id: Ident.card2, title: "Second", order: "2048") await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Second") } #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second"]) #expect(store.bannerRows.isEmpty) } // MARK: Vanish and return @Test("A vanished root locks the board read-only, and the root's return clears it") func vanishAndReturn() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) defer { registry.release(store) } let lastGood = store.snapshot await settle() // The folder is deleted in Finder while the board is open. Bookmark re-resolution finds // nothing that exists, and the last-known path is gone too. try FileManager.default.removeItem(at: fixture.root) await waitUntil { store.readOnlyLock == .vanishedRoot } #expect(store.readOnlyLock == .vanishedRoot) // Wait for the vanished state to *settle* before recreating anything, and not for tidiness: // entering the lock re-attaches the watcher at the missing path, and that re-attach owes a // debounced reconciling reload. Recreating the folder inside that 200 ms window would let // the reload find the root already back and clear the lock without a root change ever being // delivered — a legitimate recovery, but a different one from the one under test here. // Waiting for that reload to land and fail pins the sequence to the real-world shape: the // root comes back later, and its *creation* is what recovers the board. await waitUntil { store.reloadFailure != nil } await store.awaitQuiescence() #expect(store.reloadFailure != nil, "the re-attach's reconciling reload fails against the missing root") // The last-good snapshot is still on screen — that is the whole point of the lock. #expect(store.snapshot == lastGood) #expect(store.readOnlyLock == .vanishedRoot, "a failed reload never lifts the lock") // And the strip leads with the lock. (The re-attach's reconciling reload fails against the // missing root, so a breakage row stands behind it; the *lock* is what comes first.) guard case .readOnlyLock(.vanishedRoot) = store.bannerRows.first else { Issue.record("expected the lock row to lead, got \(store.bannerRows.map(\.id))") return } // Every write is refused — nothing would land anywhere. do { try store.performWrite { () throws(BoardWriteError) -> Void in try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in } } Issue.record("expected the locked board to refuse the write") } catch let refusal as BoardStoreWriteRefusal { #expect(refusal == .readOnlyLocked(.vanishedRoot)) } #expect(store.banners.oneShots.isEmpty, "a refusal is not a failed write") // The root returns: a Finder undo, a remount, a folder recreated where the board was. Built // aside and moved into place in one step, so the path appears as a whole board rather than // as a directory that is filled in over several reload debounces. let staging = FileManager.default.temporaryDirectory .appendingPathComponent("RootRecoveryTests-staging-\(UUID().uuidString)", isDirectory: true) try makeBoard(at: staging) try writeCard(inBoard: staging, lane: Ident.lane1, id: Ident.card3, title: "Third", order: "3072") try FileManager.default.moveItem(at: staging, to: fixture.root) // FSEvents was left watching the path precisely so this creation would be reported. await waitUntil { store.readOnlyLock == nil } #expect(store.readOnlyLock == nil, "a successful reload proves the root came back") await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Third") } #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Third"]) #expect(store.reloadFailure == nil) #expect(store.bannerRows.isEmpty) // The recreated folder is a different file than the one that was deleted, so the entry had // to be re-keyed — otherwise the next window to open this board would get a second store // over a board already on screen. await waitUntil { registry.liveStore(for: fixture.root) === store } #expect(registry.liveStore(for: fixture.root) === store) #expect(registry.openBoardCount == 1) // And writes are live again. try store.performWrite { () throws(BoardWriteError) -> Void in try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in } } #expect(store.banners.oneShots.isEmpty) } // MARK: The writability probe @Test("The unwritable-location lock clears only on a reconciling reload whose probe passes") func unwritableLockClearsOnlyOnAReconcilingProbe() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // The probe has to be honest, so the root is made genuinely unwritable — `r-x`, which still // reads perfectly. That is the whole difficulty of this case: the board loads fine. try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path) store.enterUnwritableLock(.permissionDenied) #expect(store.readOnlyLock == .unwritableLocation(.permissionDenied)) // A foreign reload succeeds — and clears nothing. Loading proves nothing about writing, // which is exactly why this lock's clearing rule is not the other two's. store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.reloadFailure == nil, "an unwritable root still reads") #expect(store.readOnlyLock == .unwritableLocation(.permissionDenied)) // Neither does a reconciling one while the permission is still what it was. store.handleWatcherEvent(.treeChanged(.reconciling)) await store.awaitQuiescence() #expect(store.readOnlyLock == .unwritableLocation(.permissionDenied)) // The permission is fixed. Nothing announces that — a `chmod` in a terminal fires no event // the board would act on — so the lock stands until the next reconciling sweep (wake, app // activation) re-probes. try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.readOnlyLock == .unwritableLocation(.permissionDenied), "only a reconciling reload re-probes") store.handleWatcherEvent(.treeChanged(.reconciling)) await store.awaitQuiescence() #expect(store.readOnlyLock == nil, "a fixed permission clears the lock without ceremony") #expect(store.bannerRows.isEmpty) } /// "The probe is symmetric (settled): … a volume gone read-only mid-session *raises* it at the /// next probe — banner up front, not every gesture failing one at a time." @Test("A reconciling reload that finds the root unwritable raises the lock") func theProbeIsSymmetric() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) #expect(store.readOnlyLock == nil, "a writable board opens unlocked") // The root goes read-only under the open board. Nothing announces it: a `chmod` in a // terminal is not a tree change, and a mid-session remount is not one either. try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path) // A foreign reload is not a probe. Between probes, "a write that hits the newly read-only // volume fails as an ordinary one-shot" — the condition is not yet standing. store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.reloadFailure == nil, "an unwritable root still reads") #expect(store.readOnlyLock == nil, "only a reconciling reload probes") // The next reconciliation converts the condition into the standing lock, cause and all. store.handleWatcherEvent(.treeChanged(.reconciling)) await store.awaitQuiescence() #expect(store.readOnlyLock == .unwritableLocation(.permissionDenied)) #expect(store.bannerRows.map(\.id) == ["read-only-lock"]) #expect(store.isReadOnly) // And it is a *lock*, not a broken board: the snapshot is intact and reads stay live. #expect(store.reloadFailure == nil) #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First"]) // Symmetric in the other direction, from a lock this probe raised rather than one the open // flow armed — the same rule, so the same clearing. try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path) store.handleWatcherEvent(.treeChanged(.reconciling)) await store.awaitQuiescence() #expect(store.readOnlyLock == nil, "a fixed permission clears the lock without ceremony") #expect(store.bannerRows.isEmpty) } /// The raise is announced exactly once, in the banner's own words, through the single /// per-reload sentence `land` posts — not a second voice of the probe's own. @Test("A probe-raised lock speaks the banner's line, once") func theRaiseIsAnnouncedOnce() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) var spoken: [String] = [] store.announce = { if let phrase = $0 { spoken.append(phrase) } } try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path) store.handleWatcherEvent(.treeChanged(.reconciling)) await store.awaitQuiescence() #expect(spoken == ["Error: You don't have permission to change this folder — showing the last good view, read-only"]) // A second reconciling reload finds the same condition: the row is already standing, so // nothing is said again. store.handleWatcherEvent(.treeChanged(.reconciling)) await store.awaitQuiescence() #expect(spoken.count == 1) } /// A vanished root that comes back on a read-only volume must not end up *unlocked*: the /// sibling lock clears on the reload's success, and the probe in the same pass raises the honest /// one. This drives the two halves directly, since a real remount is not a headless act. @Test("A sibling lock clearing does not leave an unwritable root unlocked") func aSiblingLockYieldsToTheProbe() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.enterVanishedRootLock() #expect(store.readOnlyLock == .vanishedRoot) try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path) store.handleWatcherEvent(.treeChanged(.reconciling)) await store.awaitQuiescence() #expect(store.readOnlyLock == .unwritableLocation(.permissionDenied)) } // MARK: The probe's classification /// The classifier is pure, so the row `access(2)` alone cannot distinguish — a read-only volume, /// which fails `access(2)` exactly like a `r-x` folder does — is testable without a DMG. @Test("A read-only volume outranks permission denial, whatever access(2) says") func theVolumeAnswerWins() { #expect(WritabilityProbe.classify(volumeIsReadOnly: true, isWritable: false) == .readOnlyVolume) // The row that matters: on a mounted DMG both facts are true at once, and naming the folder // would send the user to a Get Info panel that cannot help. #expect(WritabilityProbe.classify(volumeIsReadOnly: true, isWritable: true) == .readOnlyVolume) } @Test("A writable volume leaves the folder to answer for itself") func thePermissionAnswerIsTheFallback() { #expect(WritabilityProbe.classify(volumeIsReadOnly: false, isWritable: false) == .permissionDenied) #expect(WritabilityProbe.classify(volumeIsReadOnly: false, isWritable: true) == nil) // A volume that will not answer degrades to access(2) — still honest about *whether*, and // it describes the refusal as the folder's doing. #expect(WritabilityProbe.classify(volumeIsReadOnly: nil, isWritable: false) == .permissionDenied) #expect(WritabilityProbe.classify(volumeIsReadOnly: nil, isWritable: true) == nil) } @Test("The live probe reads the filesystem, not a cached resource value") func theLiveProbeSeesChanges() throws { let fixture = try makeBoard() defer { fixture.tearDown() } #expect(WritabilityProbe.probe(fixture.root) == nil) try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path) #expect(WritabilityProbe.probe(fixture.root) == .permissionDenied) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path) #expect(WritabilityProbe.probe(fixture.root) == nil) } // MARK: The probe at open /// "An unwritable board location enters the read-only lock at open" — through the registry, /// which is the seam every open path funnels through. @Test("Acquiring an unwritable board opens it, locked") func openTimeProbeRaisesTheLock() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path) let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) defer { registry.release(store) } // The open **succeeded** — the lock is not a refusal, and viewing an archived board is the // legitimate errand the read affordances exist for. #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First"]) #expect(store.reloadFailure == nil) // And the lock was up before anything could act on it. #expect(store.readOnlyLock == .unwritableLocation(.permissionDenied)) #expect(store.bannerRows.map(\.id) == ["read-only-lock"]) // Writes are refused as a policy refusal, not as an I/O failure, and nothing is posted on // top of the standing row. #expect(throws: BoardStoreWriteRefusal.readOnlyLocked(.unwritableLocation(.permissionDenied))) { try store.performWrite { () throws(BoardWriteError) -> Void in } } #expect(store.banners.oneShots.isEmpty) } /// "The open-time agent-guide write is skipped-with-log, the `CLAUDE.user.md`-taken precedent." /// The lock is what skips it: `acquire` probes before it calls `refreshAgentGuide()`. @Test("The open-time agent-guide write is skipped under the lock") func openTimeGuideWriteIsSkipped() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path) let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) defer { registry.release(store) } #expect(!FileManager.default.fileExists(atPath: fixture.root.appendingPathComponent(AgentGuide.filename).path)) // Skipped with a log, never with a banner: the user did not ask for this file. #expect(store.banners.oneShots.isEmpty) #expect(store.bannerRows.map(\.id) == ["read-only-lock"]) } @Test("A writable board acquires unlocked, and gets its guide") func openTimeProbePassesQuietly() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let registry = BoardStoreRegistry() let store = try registry.acquire(fixture.root) defer { registry.release(store) } #expect(store.readOnlyLock == nil) #expect(store.bannerRows.isEmpty) #expect(FileManager.default.fileExists(atPath: fixture.root.appendingPathComponent(AgentGuide.filename).path)) } // MARK: The lock's scope, at this cause /// The settled carve-out: "under the unwritable-location lock alone, Save as Template stays /// live" — copy-out is a read — "unless an open Edit or raw-source session holds unsaved /// content", and Duplicate stays disabled even there. @Test("Save as Template survives this lock alone; the other two disable it") func saveAsTemplateCarveOut() { let live = SaveAsTemplateCommand.allowsSave( lock: .unwritableLocation(.readOnlyVolume), isEditingInline: false, hasUnsavedCardContent: false ) #expect(live, "archiving the read-only DMG board being inspected is a legitimate errand") // The gate is the hazard, not the provenance: unsaved content the lock's suspended saves // cannot flush would be silently missed by the template. #expect(!SaveAsTemplateCommand.allowsSave( lock: .unwritableLocation(.readOnlyVolume), isEditingInline: false, hasUnsavedCardContent: true )) // Both causes are the same lock, so both carve out. #expect(SaveAsTemplateCommand.allowsSave( lock: .unwritableLocation(.permissionDenied), isEditingInline: false, hasUnsavedCardContent: false )) // An open inline title editor holds a pending change no flush can reach. #expect(!SaveAsTemplateCommand.allowsSave( lock: .unwritableLocation(.permissionDenied), isEditingInline: true, hasUnsavedCardContent: false )) // The other two locks disable it outright, unsaved content or not. for lock: ReadOnlyLockReason in [.vanishedRoot, .bracketedReloadFailed] { #expect(!SaveAsTemplateCommand.allowsSave( lock: lock, isEditingInline: false, hasUnsavedCardContent: false )) } #expect(SaveAsTemplateCommand.allowsSave(lock: nil, isEditingInline: false, hasUnsavedCardContent: false)) } /// Duplicate is `acceptsBoardMutations`, which is the bare lock — so it stays disabled under /// this cause too ("its destination is the same unwritable parent"). @Test("Duplicate stays disabled under the unwritable-location lock") func duplicateStaysDisabled() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) #expect(store.acceptsBoardMutations) store.enterUnwritableLock(.readOnlyVolume) #expect(!store.acceptsBoardMutations) } }