|
|
|
@@ -221,7 +221,7 @@ struct RootRecoveryTests {
|
|
|
|
|
#expect(store.banners.oneShots.isEmpty)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: The writability clearing rule
|
|
|
|
|
// MARK: The writability probe
|
|
|
|
|
|
|
|
|
|
@Test("The unwritable-location lock clears only on a reconciling reload whose probe passes")
|
|
|
|
|
func unwritableLockClearsOnlyOnAReconcilingProbe() async throws {
|
|
|
|
@@ -232,20 +232,20 @@ struct RootRecoveryTests {
|
|
|
|
|
// 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()
|
|
|
|
|
#expect(store.readOnlyLock == .unwritableLocation)
|
|
|
|
|
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)
|
|
|
|
|
#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)
|
|
|
|
|
#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
|
|
|
|
@@ -253,7 +253,7 @@ struct RootRecoveryTests {
|
|
|
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path)
|
|
|
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
|
|
|
await store.awaitQuiescence()
|
|
|
|
|
#expect(store.readOnlyLock == .unwritableLocation, "only a reconciling reload re-probes")
|
|
|
|
|
#expect(store.readOnlyLock == .unwritableLocation(.permissionDenied), "only a reconciling reload re-probes")
|
|
|
|
|
|
|
|
|
|
store.handleWatcherEvent(.treeChanged(.reconciling))
|
|
|
|
|
await store.awaitQuiescence()
|
|
|
|
@@ -261,20 +261,239 @@ struct RootRecoveryTests {
|
|
|
|
|
#expect(store.bannerRows.isEmpty)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test("A reconciling reload that finds the root unwritable does not raise the lock by itself")
|
|
|
|
|
func theProbeOnlyClears() async throws {
|
|
|
|
|
/// "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()
|
|
|
|
|
|
|
|
|
|
// Arming the lock is the open flow's job (m4). Inferring it from a probe here would be a
|
|
|
|
|
// policy decision this layer has not been asked to make — recorded as a test so the
|
|
|
|
|
// asymmetry is deliberate rather than forgotten.
|
|
|
|
|
#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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|