mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user