fix(templates): site-status-checker dashboard no longer lies before first run

The template's dashboard shipped with two hardcoded example URLs
(https://example.com + https://example.org) baked into a "Configured
Sites" list widget, and the widget title still said "from sites.txt"
— stale from the v1.0.0 layout before we moved to config.json.

After the v1.1.0 configure-on-install flow lands, the user fills in a
real sites list through the Configure form (which correctly lands in
`.scarf/config.json` — the editor modal confirms that), but the
dashboard still rendered the baked-in example URLs. The agent would
overwrite them on the first cron run, but until then the dashboard
misrepresents reality.

Two orthogonal paths to fix this — populate the dashboard's items
from config.json at install time (requires Scarf-side template-value
interpolation, which is a v2.3.1 feature), or ship a dashboard that
clearly advertises "nothing has run yet." Taking the second path for
v1.1.0: replace the example URLs with a single placeholder row with
status "pending" pointing the user at running the check. The agent
replaces the row with real data on the first cron run.

Also: widget title fixed ("Watched Sites (populated after first run)"
instead of the stale sites.txt reference), top-of-dashboard description
updated, and the Quick Start text now mentions the Configuration
button as the way to set sites, not the long-gone sites.txt.

Bundle + catalog rebuilt; ProjectTemplateExampleTemplateTests still
passes (it asserts against cron prompt + schema shape, not dashboard
content, so the dashboard edit doesn't affect it).

---

Secondary fix: test deflake from the saveRegistry throw change.

Making saveRegistry throw exposed a pre-existing parallel-test race:
three suites (ProjectTemplateInstallerTests,
ProjectTemplateUninstallerTests, ProjectTemplateConfigInstallTests)
all write to the real `~/.hermes/scarf/projects.json`. Swift Testing's
`.serialized` trait only serializes within a single suite — multiple
suites still run in parallel. Before, writes silently failed on the
racing-loser side and tests passed by accident; now the loser's test
throws "couldn't be saved in the folder 'scarf'".

Added TestRegistryLock — a module-level NSLock that all three suites'
snapshotRegistry/restoreRegistry helpers share. acquireAndSnapshot()
locks + reads; restore(_:) writes + unlocks. The paired
snapshot-in-test-body / defer-restore pattern keeps acquire + release
balanced. Replaced the three per-suite copies of the helpers with
thin delegates to the shared lock.

Verified by running the full test suite 3 consecutive times: 53/53
tests pass each run, no flakes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-23 14:52:46 +02:00
parent 3bd95de8f4
commit 3af99d9d9c
4 changed files with 66 additions and 31 deletions
+60 -24
View File
@@ -2,6 +2,42 @@ import Testing
import Foundation
@testable import scarf
/// Cross-suite serialization lock for tests that touch the real
/// `~/.hermes/scarf/projects.json`. Swift Testing's `.serialized` trait
/// only serializes tests WITHIN a suite multiple suites still run in
/// parallel. Three suites in this file write to the same file and
/// previously raced each other silently (saveRegistry used to swallow
/// write failures); now that saveRegistry throws, the race surfaces.
///
/// The lock is acquired by `acquireAndSnapshot()` at the top of each
/// registry-touching test and released by `restore(_:)` via the test's
/// `defer`. Asymmetric acquire-in-one-fn / release-in-another looks
/// unusual but the snapshot/restore pairing is so tight (every test
/// defers the restore) that it's reliable in practice.
final class TestRegistryLock: @unchecked Sendable {
static let shared = TestRegistryLock()
private let lock = NSLock()
/// Acquire the cross-suite lock and snapshot the registry. Pair
/// every call with a `defer { TestRegistryLock.restore(snapshot) }`.
static func acquireAndSnapshot() -> Data? {
shared.lock.lock()
let path = ServerContext.local.paths.projectsRegistry
return try? Data(contentsOf: URL(fileURLWithPath: path))
}
/// Restore the registry from snapshot and release the lock.
static func restore(_ snapshot: Data?) {
defer { shared.lock.unlock() }
let path = ServerContext.local.paths.projectsRegistry
if let snapshot {
try? snapshot.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
}
}
/// Exercises the service's ability to unpack, parse, and validate bundles.
/// Doesn't touch the installer see `ProjectTemplateInstallerTests` so
/// these don't need write access to ~/.hermes.
@@ -351,18 +387,18 @@ import Foundation
/// Read the raw bytes of the current projects.json so we can restore
/// it byte-for-byte after the test. `nil` means the file didn't exist
/// restore by deleting whatever got created.
// Delegates to TestRegistryLock so tests across this suite + the
// two other registry-touching suites share one lock. Every
// `snapshotRegistry()` call acquires; the paired
// `restoreRegistry(_:)` defer releases. Without this, parallel
// test runs race on `~/.hermes/scarf/projects.json` writes and
// the saveRegistry throw surfaces the collision as a test failure.
nonisolated private static func snapshotRegistry() -> Data? {
let path = ServerContext.local.paths.projectsRegistry
return try? Data(contentsOf: URL(fileURLWithPath: path))
TestRegistryLock.acquireAndSnapshot()
}
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
let path = ServerContext.local.paths.projectsRegistry
if let snapshot {
try? snapshot.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
TestRegistryLock.restore(snapshot)
}
}
@@ -476,18 +512,18 @@ import Foundation
// ProjectTemplateInstallerTests small helper, not worth a shared
// fixture file for one more suite).
// Delegates to TestRegistryLock so tests across this suite + the
// two other registry-touching suites share one lock. Every
// `snapshotRegistry()` call acquires; the paired
// `restoreRegistry(_:)` defer releases. Without this, parallel
// test runs race on `~/.hermes/scarf/projects.json` writes and
// the saveRegistry throw surfaces the collision as a test failure.
nonisolated private static func snapshotRegistry() -> Data? {
let path = ServerContext.local.paths.projectsRegistry
return try? Data(contentsOf: URL(fileURLWithPath: path))
TestRegistryLock.acquireAndSnapshot()
}
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
let path = ServerContext.local.paths.projectsRegistry
if let snapshot {
try? snapshot.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
TestRegistryLock.restore(snapshot)
}
}
@@ -753,18 +789,18 @@ import Foundation
// MARK: - Registry snapshot helpers (dup'd from ProjectTemplateInstallerTests)
// Delegates to TestRegistryLock so tests across this suite + the
// two other registry-touching suites share one lock. Every
// `snapshotRegistry()` call acquires; the paired
// `restoreRegistry(_:)` defer releases. Without this, parallel
// test runs race on `~/.hermes/scarf/projects.json` writes and
// the saveRegistry throw surfaces the collision as a test failure.
nonisolated private static func snapshotRegistry() -> Data? {
let path = ServerContext.local.paths.projectsRegistry
return try? Data(contentsOf: URL(fileURLWithPath: path))
TestRegistryLock.acquireAndSnapshot()
}
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
let path = ServerContext.local.paths.projectsRegistry
if let snapshot {
try? snapshot.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
TestRegistryLock.restore(snapshot)
}
}