From 3af99d9d9c4e7da97e30818c660b7747e7250616 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 23 Apr 2026 14:52:46 +0200 Subject: [PATCH] fix(templates): site-status-checker dashboard no longer lies before first run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scarf/scarfTests/ProjectTemplateTests.swift | 84 +++++++++++++----- .../site-status-checker.scarftemplate | Bin 6797 -> 6880 bytes .../staging/dashboard.json | 9 +- templates/catalog.json | 4 +- 4 files changed, 66 insertions(+), 31 deletions(-) diff --git a/scarf/scarfTests/ProjectTemplateTests.swift b/scarf/scarfTests/ProjectTemplateTests.swift index a79a546..c9dae32 100644 --- a/scarf/scarfTests/ProjectTemplateTests.swift +++ b/scarf/scarfTests/ProjectTemplateTests.swift @@ -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) } } diff --git a/templates/awizemann/site-status-checker/site-status-checker.scarftemplate b/templates/awizemann/site-status-checker/site-status-checker.scarftemplate index 81d75d108bbdd1e388076be4b0fdd25a95332826..1846f604730bad6fe8772662370d946b09786c9d 100644 GIT binary patch delta 1276 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 diff --git a/templates/awizemann/site-status-checker/staging/dashboard.json b/templates/awizemann/site-status-checker/staging/dashboard.json index 6693994..de2eee0 100644 --- a/templates/awizemann/site-status-checker/staging/dashboard.json +++ b/templates/awizemann/site-status-checker/staging/dashboard.json @@ -1,7 +1,7 @@ { "version": 1, "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" }, "sections": [ { @@ -40,10 +40,9 @@ "widgets": [ { "type": "list", - "title": "Configured Sites (from sites.txt)", + "title": "Watched Sites (populated after first run)", "items": [ - { "text": "https://example.com", "status": "unknown" }, - { "text": "https://example.org", "status": "unknown" } + { "text": "Run the check once to populate — the agent reads your Configuration and fills this list with live status.", "status": "pending" } ] } ] @@ -56,7 +55,7 @@ "type": "text", "title": "Quick Start", "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." } ] } diff --git a/templates/catalog.json b/templates/catalog.json index d717bbe..59ca326 100644 --- a/templates/catalog.json +++ b/templates/catalog.json @@ -7,8 +7,8 @@ "name": "Alan Wizemann", "url": "https://github.com/awizemann/scarf" }, - "bundleSha256": "ce68cc20cc67fe688a7ddf0638d35dce3247ba7ed234e6f9d99a1ad3964a81e0", - "bundleSize": 6797, + "bundleSha256": "3274964418b7cb1e4df2980936e16f9afdf3d4e90c91ae76dead6e5065306818", + "bundleSize": 6880, "category": "monitoring", "config": { "modelRecommendation": {