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 a7a174d2c6
commit 60820f9cfb
4 changed files with 66 additions and 31 deletions
+60 -24
View File
@@ -2,6 +2,42 @@ import Testing
import Foundation import Foundation
@testable import scarf @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. /// Exercises the service's ability to unpack, parse, and validate bundles.
/// Doesn't touch the installer see `ProjectTemplateInstallerTests` so /// Doesn't touch the installer see `ProjectTemplateInstallerTests` so
/// these don't need write access to ~/.hermes. /// 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 /// 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 /// it byte-for-byte after the test. `nil` means the file didn't exist
/// restore by deleting whatever got created. /// 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? { nonisolated private static func snapshotRegistry() -> Data? {
let path = ServerContext.local.paths.projectsRegistry TestRegistryLock.acquireAndSnapshot()
return try? Data(contentsOf: URL(fileURLWithPath: path))
} }
nonisolated private static func restoreRegistry(_ snapshot: Data?) { nonisolated private static func restoreRegistry(_ snapshot: Data?) {
let path = ServerContext.local.paths.projectsRegistry TestRegistryLock.restore(snapshot)
if let snapshot {
try? snapshot.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
} }
} }
@@ -476,18 +512,18 @@ import Foundation
// ProjectTemplateInstallerTests small helper, not worth a shared // ProjectTemplateInstallerTests small helper, not worth a shared
// fixture file for one more suite). // 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? { nonisolated private static func snapshotRegistry() -> Data? {
let path = ServerContext.local.paths.projectsRegistry TestRegistryLock.acquireAndSnapshot()
return try? Data(contentsOf: URL(fileURLWithPath: path))
} }
nonisolated private static func restoreRegistry(_ snapshot: Data?) { nonisolated private static func restoreRegistry(_ snapshot: Data?) {
let path = ServerContext.local.paths.projectsRegistry TestRegistryLock.restore(snapshot)
if let snapshot {
try? snapshot.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
} }
} }
@@ -753,18 +789,18 @@ import Foundation
// MARK: - Registry snapshot helpers (dup'd from ProjectTemplateInstallerTests) // 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? { nonisolated private static func snapshotRegistry() -> Data? {
let path = ServerContext.local.paths.projectsRegistry TestRegistryLock.acquireAndSnapshot()
return try? Data(contentsOf: URL(fileURLWithPath: path))
} }
nonisolated private static func restoreRegistry(_ snapshot: Data?) { nonisolated private static func restoreRegistry(_ snapshot: Data?) {
let path = ServerContext.local.paths.projectsRegistry TestRegistryLock.restore(snapshot)
if let snapshot {
try? snapshot.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
} }
} }
@@ -1,7 +1,7 @@
{ {
"version": 1, "version": 1,
"title": "Site Status", "title": "Site Status",
"description": "Daily uptime check for your watched URLs. The stat widgets and list update automatically when the cron job runs.", "description": "Daily uptime check for your watched URLs. The stat widgets and the sites list populate after the first cron run; before that, the list mirrors what the agent last wrote.",
"theme": { "accent": "green" }, "theme": { "accent": "green" },
"sections": [ "sections": [
{ {
@@ -40,10 +40,9 @@
"widgets": [ "widgets": [
{ {
"type": "list", "type": "list",
"title": "Configured Sites (from sites.txt)", "title": "Watched Sites (populated after first run)",
"items": [ "items": [
{ "text": "https://example.com", "status": "unknown" }, { "text": "Run the check once to populate — the agent reads your Configuration and fills this list with live status.", "status": "pending" }
{ "text": "https://example.org", "status": "unknown" }
] ]
} }
] ]
@@ -56,7 +55,7 @@
"type": "text", "type": "text",
"title": "Quick Start", "title": "Quick Start",
"format": "markdown", "format": "markdown",
"content": "**1.** Enable the `[tmpl:awizemann/site-status-checker] Check site status` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**2.** Edit `sites.txt` in this project's folder to replace the placeholder URLs with the sites you actually want to watch.\n\n**3.** Ask your agent: *\"Run the site status check now.\"* The dashboard refreshes and a new entry appears at the top of `status-log.md`.\n\n**4.** Daily at 9 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec." "content": "**1.** Review your configuration — click the **slider icon** (top-right of this dashboard) to open Configuration. The sites you enter there are what the cron job will check.\n\n**2.** Enable the `[tmpl:awizemann/site-status-checker] Check site status` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**3.** Ask your agent: *\"Run the site status check now.\"* The Watched Sites list populates, the stat widgets update, and a new entry lands at the top of `status-log.md`.\n\n**4.** Daily at 9 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec."
} }
] ]
} }
+2 -2
View File
@@ -7,8 +7,8 @@
"name": "Alan Wizemann", "name": "Alan Wizemann",
"url": "https://github.com/awizemann/scarf" "url": "https://github.com/awizemann/scarf"
}, },
"bundleSha256": "ce68cc20cc67fe688a7ddf0638d35dce3247ba7ed234e6f9d99a1ad3964a81e0", "bundleSha256": "3274964418b7cb1e4df2980936e16f9afdf3d4e90c91ae76dead6e5065306818",
"bundleSize": 6797, "bundleSize": 6880,
"category": "monitoring", "category": "monitoring",
"config": { "config": {
"modelRecommendation": { "modelRecommendation": {