mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(templates): hackernews-digest template + dogfooding test harness
First pass of the dogfooding-templates initiative. Each pre-release cycle ships one new official `.scarftemplate` and uses installing/exercising that template as the regression test. v1 lands the harness scaffolding plus the first template under it. - HackerNews Daily Digest template (`templates/awizemann/hackernews-digest/`): config-driven (min_score / max_items / topics) cron-only template. No secrets — keeps the harness minimal until the fake-Keychain shim lands. Bundle validates against `tools/build-catalog.py`; entry added to `templates/catalog.json`. - `SCARF_HERMES_HOME` env-var override at `HermesProfileResolver` — the seam every Layer-B test relies on to drive Scarf against an isolated Hermes home. Bypasses cache + active_profile lookup; rejects relative paths. 5 unit tests + 3 ServerContext integration tests. - `TestModeFlags.shared.isTestMode` — reads `--scarf-test-mode` once from `CommandLine.arguments`. Wiring only; gating sites (Sparkle, capability probe, first-run walkthrough) land as Layer-B exercises them. - Layer A (`scarf/scarfTests/TemplateE2ETests.swift`): parses + plans the shipped HN bundle the way the app does at install time; asserts manifest, config schema, dashboard widgets, and cron prompt contract. Mirrors the existing site-status-checker coverage. - Layer B scaffold (`scarf/scarfUITests/TemplateInstallUITests.swift`): proves the launch-arg + env-var plumbing reaches Scarf. Full install click-through deferred until fixture-Hermes-home and accessibility IDs land. Wiki pages added separately on the `.wiki-worktree` branch: - `Template-Ideas.md` — backlog of 9 v1-feasible templates + full-spec v3 epic for Project-Site-as-Living-Surface (eBay listings use case). - `Test-Harness.md` — contributor guide for extending the harness. Verification: scarfTests 124/124, ScarfCore 220/220, new Layer A 3/3, Layer B scaffold 1/1, build-catalog.py + its 28 unit tests all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,7 +51,19 @@ public enum HermesProfileResolver {
|
||||
/// Returns the default `~/.hermes` when no profile is active OR when
|
||||
/// the configured profile is invalid (logged) — so the worst-case
|
||||
/// failure mode is "Scarf shows what it always showed before."
|
||||
///
|
||||
/// **Test override.** Setting `SCARF_HERMES_HOME` in the environment
|
||||
/// pins this resolver to the supplied absolute path and bypasses both
|
||||
/// the cache and the `active_profile` lookup. Used by the E2E test
|
||||
/// harness (`TemplateE2ETests`, `TemplateInstallUITests`) to drive
|
||||
/// Scarf against an isolated tmpdir Hermes home so the user's real
|
||||
/// `~/.hermes` is never touched. Read on every call (cheap; a single
|
||||
/// `ProcessInfo` lookup) so tests can flip it across test methods
|
||||
/// without stale-cache surprises.
|
||||
public static func resolveLocalHome() -> String {
|
||||
if let override = scarfHermesHomeOverride() {
|
||||
return override
|
||||
}
|
||||
return refreshIfNeeded().home
|
||||
}
|
||||
|
||||
@@ -60,9 +72,30 @@ public enum HermesProfileResolver {
|
||||
/// reading from (issue #50 follow-up: prevents the next variant
|
||||
/// of "where's my data — wrong profile" by making it visible).
|
||||
public static func activeProfileName() -> String {
|
||||
if scarfHermesHomeOverride() != nil {
|
||||
return "test-override"
|
||||
}
|
||||
return refreshIfNeeded().name
|
||||
}
|
||||
|
||||
/// Read `SCARF_HERMES_HOME` from the environment. Returns `nil` when
|
||||
/// unset or empty so production callers fall through to the profile
|
||||
/// resolver. The override must be an absolute path — relative paths
|
||||
/// are rejected (would land relative to the cwd of whatever process
|
||||
/// happened to invoke the resolver, which is not what tests want).
|
||||
private static func scarfHermesHomeOverride() -> String? {
|
||||
guard let raw = ProcessInfo.processInfo.environment["SCARF_HERMES_HOME"] else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard trimmed.hasPrefix("/") else {
|
||||
logger.warning("SCARF_HERMES_HOME=\(trimmed, privacy: .public) is not absolute; ignoring.")
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/// Force a re-read on the next call, regardless of TTL. Test helper.
|
||||
public static func invalidateCache() {
|
||||
lock.withLock { $0.resolvedAt = .distantPast }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Process-wide toggles for test-mode launches.
|
||||
///
|
||||
/// Read `CommandLine.arguments` once at first access and cache the result so
|
||||
/// any code path can ask `TestModeFlags.shared.isTestMode` without paying for
|
||||
/// a re-scan. The harness sets `--scarf-test-mode` from XCUITest's
|
||||
/// `XCUIApplication.launchArguments` and pairs it with `SCARF_HERMES_HOME`
|
||||
/// (read by `HermesProfileResolver`) to drive Scarf against an isolated
|
||||
/// Hermes home.
|
||||
///
|
||||
/// The flags themselves don't do anything on their own — they're hook points
|
||||
/// for production code paths to gate behavior. v1 lands the wiring; the
|
||||
/// gating sites (Sparkle update prompt, capability live-probe, first-run
|
||||
/// walkthrough) are added incrementally as the harness exercises them and
|
||||
/// surfaces flakes.
|
||||
public struct TestModeFlags: Sendable {
|
||||
/// True when the process was launched with `--scarf-test-mode`. Read
|
||||
/// once from `CommandLine.arguments`; never mutated.
|
||||
public let isTestMode: Bool
|
||||
|
||||
/// Default singleton — cached on first access. Production code reads
|
||||
/// this; tests that need a different shape construct their own value.
|
||||
public static let shared: TestModeFlags = TestModeFlags(
|
||||
arguments: CommandLine.arguments
|
||||
)
|
||||
|
||||
/// Constructor exposed for tests so a synthetic argv can be passed
|
||||
/// without involving the real `CommandLine`. Production callers use
|
||||
/// `.shared`.
|
||||
public init(arguments: [String]) {
|
||||
self.isTestMode = arguments.contains("--scarf-test-mode")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
/// Exercises the `SCARF_HERMES_HOME` test-mode override on `HermesProfileResolver`.
|
||||
/// The override is the seam every E2E test relies on — without it, tests would
|
||||
/// touch the user's real `~/.hermes`. Serialized because we mutate process-wide
|
||||
/// environment.
|
||||
@Suite(.serialized)
|
||||
struct HermesProfileResolverOverrideTests {
|
||||
|
||||
private static let envKey = "SCARF_HERMES_HOME"
|
||||
|
||||
@Test func absoluteOverrideTakesPrecedence() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
let tmp = NSTemporaryDirectory().appending("scarf-test-home-\(UUID().uuidString)")
|
||||
setenv(Self.envKey, tmp, 1)
|
||||
|
||||
#expect(HermesProfileResolver.resolveLocalHome() == tmp)
|
||||
#expect(HermesProfileResolver.activeProfileName() == "test-override")
|
||||
}
|
||||
|
||||
@Test func emptyOverrideFallsThrough() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
setenv(Self.envKey, "", 1)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
// Empty override is treated as "no override" — fall through to
|
||||
// the normal profile resolver. Result must be the user's real
|
||||
// home (or whatever the resolver chose), never the empty string.
|
||||
let resolved = HermesProfileResolver.resolveLocalHome()
|
||||
#expect(!resolved.isEmpty)
|
||||
#expect(resolved.hasSuffix("/.hermes") || resolved.contains("/.hermes/profiles/"))
|
||||
}
|
||||
|
||||
@Test func relativeOverrideIsRejected() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
setenv(Self.envKey, "relative/path", 1)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
// Relative path → ignored, fall back to default. We don't want
|
||||
// a typo to land Scarf reading from `cwd/relative/path`.
|
||||
let resolved = HermesProfileResolver.resolveLocalHome()
|
||||
#expect(!resolved.hasSuffix("relative/path"))
|
||||
}
|
||||
|
||||
@Test func unsetOverrideUsesProfileResolver() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
unsetenv(Self.envKey)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
let resolved = HermesProfileResolver.resolveLocalHome()
|
||||
#expect(!resolved.isEmpty)
|
||||
}
|
||||
|
||||
@Test func overrideBypassesCache() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
let first = NSTemporaryDirectory().appending("scarf-cache-bypass-1-\(UUID().uuidString)")
|
||||
let second = NSTemporaryDirectory().appending("scarf-cache-bypass-2-\(UUID().uuidString)")
|
||||
|
||||
setenv(Self.envKey, first, 1)
|
||||
#expect(HermesProfileResolver.resolveLocalHome() == first)
|
||||
|
||||
// Flip the env var without invalidating the cache. The override
|
||||
// path reads env on every call, so the new value takes effect
|
||||
// immediately — that's the property tests rely on when sweeping
|
||||
// multiple isolated homes across test methods.
|
||||
setenv(Self.envKey, second, 1)
|
||||
#expect(HermesProfileResolver.resolveLocalHome() == second)
|
||||
}
|
||||
|
||||
private func restore(_ saved: String?) {
|
||||
if let saved {
|
||||
setenv(Self.envKey, saved, 1)
|
||||
} else {
|
||||
unsetenv(Self.envKey)
|
||||
}
|
||||
HermesProfileResolver.invalidateCache()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
/// End-to-end coverage for the dogfooding-templates harness.
|
||||
///
|
||||
/// Two suites live here:
|
||||
///
|
||||
/// 1. `HackerNewsDigestTemplateE2ETests` — exercises the shipped
|
||||
/// `awizemann/hackernews-digest` bundle the way Scarf will at install
|
||||
/// time: unpack, parse, validate the manifest + dashboard + cron
|
||||
/// against the same `ProjectTemplateService` the app uses, then build
|
||||
/// a `TemplateInstallPlan` and assert the resulting plan would write
|
||||
/// the right files in the right places. Mirrors
|
||||
/// `ProjectTemplateExampleTemplateTests.siteStatusCheckerParsesAndPlans`
|
||||
/// so each shipped template gets the same regression net.
|
||||
///
|
||||
/// 2. `ScarfHermesHomeOverrideE2ETests` — proves the `SCARF_HERMES_HOME`
|
||||
/// env-var override (added in `HermesProfileResolver`) actually steers
|
||||
/// `ServerContext.local.paths`. This is the seam the Layer-B XCUITest
|
||||
/// relies on to drive Scarf against an isolated Hermes home; if it
|
||||
/// silently regresses, UI tests would suddenly start writing into the
|
||||
/// user's real `~/.hermes`. Running it here keeps that invariant
|
||||
/// visible from the unit-test target.
|
||||
@Suite struct HackerNewsDigestTemplateE2ETests {
|
||||
|
||||
/// Parse + plan the shipped HN Digest bundle, assert its shape, and
|
||||
/// confirm the cron prompt + dashboard contract are intact.
|
||||
@Test func hackernewsDigestParsesAndPlans() throws {
|
||||
let bundle = try Self.locateExample(author: "awizemann", name: "hackernews-digest")
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
let inspection = try service.inspect(zipPath: bundle)
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
|
||||
// Manifest shape — mirror the install-time invariants the catalog
|
||||
// validator enforces, so this test fails locally before a bad
|
||||
// bundle escapes to PR.
|
||||
#expect(inspection.manifest.id == "awizemann/hackernews-digest")
|
||||
#expect(inspection.manifest.name == "HackerNews Daily Digest")
|
||||
#expect(inspection.manifest.schemaVersion == 2)
|
||||
#expect(inspection.manifest.contents.dashboard)
|
||||
#expect(inspection.manifest.contents.agentsMd)
|
||||
#expect(inspection.manifest.contents.cron == 1)
|
||||
#expect(inspection.manifest.contents.config == 3)
|
||||
#expect(inspection.manifest.contents.skills == nil)
|
||||
#expect(inspection.manifest.contents.memory == nil)
|
||||
#expect(inspection.cronJobs.count == 1)
|
||||
#expect(inspection.cronJobs.first?.name == "Daily HN digest")
|
||||
#expect(inspection.cronJobs.first?.schedule == "0 8 * * *")
|
||||
|
||||
// Config schema — three fields with the constraints the README
|
||||
// promises. The validator catches missing fields; this catches
|
||||
// wrong constraints (e.g. a default that drifts away from the
|
||||
// text in README.md, or a maxItems someone bumped without
|
||||
// updating the surrounding docs).
|
||||
let schema = try #require(inspection.manifest.config)
|
||||
#expect(schema.fields.count == 3)
|
||||
let topicsField = try #require(schema.field(for: "topics"))
|
||||
#expect(topicsField.type == .list)
|
||||
#expect(topicsField.itemType == "string")
|
||||
#expect(topicsField.required == false)
|
||||
#expect(topicsField.maxItems == 20)
|
||||
let minScoreField = try #require(schema.field(for: "min_score"))
|
||||
#expect(minScoreField.type == .number)
|
||||
#expect(minScoreField.minNumber == 1)
|
||||
#expect(minScoreField.maxNumber == 1000)
|
||||
let maxItemsField = try #require(schema.field(for: "max_items"))
|
||||
#expect(maxItemsField.type == .number)
|
||||
#expect(maxItemsField.minNumber == 5)
|
||||
#expect(maxItemsField.maxNumber == 50)
|
||||
#expect(schema.modelRecommendation?.preferred == "claude-haiku-4")
|
||||
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
||||
|
||||
#expect(plan.projectDir.hasSuffix("awizemann-hackernews-digest"))
|
||||
#expect(plan.skillsFiles.isEmpty)
|
||||
#expect(plan.memoryAppendix == nil)
|
||||
#expect(plan.cronJobs.count == 1)
|
||||
#expect(plan.configSchema?.fields.count == 3)
|
||||
#expect(plan.manifestCachePath?.hasSuffix("/.scarf/manifest.json") == true)
|
||||
|
||||
let destinations = plan.projectFiles.map(\.destinationPath)
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/config.json") })
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/manifest.json") })
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/dashboard.json") })
|
||||
|
||||
// Cron-job name gets the template tag prefix so users can
|
||||
// identify + remove it from the Cron sidebar later.
|
||||
#expect(plan.cronJobs.first?.name == "[tmpl:awizemann/hackernews-digest] Daily HN digest")
|
||||
|
||||
// The bundled dashboard.json must decode cleanly against the
|
||||
// same struct the app renders with — catches drift between
|
||||
// template-author conventions and the runtime renderer.
|
||||
let dashboardPath = inspection.unpackedDir + "/dashboard.json"
|
||||
let dashboardData = try Data(contentsOf: URL(fileURLWithPath: dashboardPath))
|
||||
let dashboard = try JSONDecoder().decode(ProjectDashboard.self, from: dashboardData)
|
||||
#expect(dashboard.title == "HackerNews Digest")
|
||||
#expect(dashboard.theme?.accent == "orange")
|
||||
// Three sections: Today's Digest (3 stat widgets), Top Stories
|
||||
// (1 list widget), How to Use (1 text widget). No webview —
|
||||
// this template intentionally doesn't expose a Site tab.
|
||||
#expect(dashboard.sections.count == 3)
|
||||
|
||||
let statsSection = dashboard.sections[0]
|
||||
#expect(statsSection.title == "Today's Digest")
|
||||
let statTitles = statsSection.widgets.filter { $0.type == "stat" }.map(\.title)
|
||||
#expect(statTitles.contains("Top Story Score"))
|
||||
#expect(statTitles.contains("Items Tracked"))
|
||||
#expect(statTitles.contains("Last Run"))
|
||||
|
||||
// The agent's contract: cron prompt references the four nouns
|
||||
// the dashboard + log files depend on. If any reference goes
|
||||
// missing, AGENTS.md and the prompt have desynced and the
|
||||
// agent will run against stale assumptions.
|
||||
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
|
||||
#expect(cronPrompt.contains("config.json"))
|
||||
#expect(cronPrompt.contains("min_score"))
|
||||
#expect(cronPrompt.contains("max_items"))
|
||||
#expect(cronPrompt.contains("topics"))
|
||||
#expect(cronPrompt.contains("dashboard.json"))
|
||||
#expect(cronPrompt.contains("digest.md"))
|
||||
#expect(cronPrompt.contains("hacker-news.firebaseio.com"))
|
||||
// {{PROJECT_DIR}} stays unresolved in the bundle — the installer
|
||||
// substitutes it at install time. A baked absolute path here
|
||||
// would follow every install to every user's machine.
|
||||
#expect(cronPrompt.contains("{{PROJECT_DIR}}"))
|
||||
}
|
||||
|
||||
nonisolated private static func locateExample(author: String, name: String) throws -> String {
|
||||
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
for _ in 0..<6 {
|
||||
let candidate = dir.appendingPathComponent("templates/\(author)/\(name)/\(name).scarftemplate")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return candidate.path
|
||||
}
|
||||
dir = dir.deletingLastPathComponent()
|
||||
}
|
||||
throw ProjectTemplateError.requiredFileMissing("templates/\(author)/\(name)/\(name).scarftemplate")
|
||||
}
|
||||
}
|
||||
|
||||
/// Smoke-tests the SCARF_HERMES_HOME override at the `ServerContext.local`
|
||||
/// integration point. The unit-level resolver tests live in
|
||||
/// `HermesProfileResolverOverrideTests`; this exercises the same seam from
|
||||
/// the surface every Scarf service actually reads — `ServerContext.paths`.
|
||||
@Suite(.serialized)
|
||||
struct ScarfHermesHomeOverrideE2ETests {
|
||||
|
||||
private static let envKey = "SCARF_HERMES_HOME"
|
||||
|
||||
@Test func overrideSteersServerContextPaths() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
let tmp = NSTemporaryDirectory().appending("scarf-e2e-home-\(UUID().uuidString)")
|
||||
setenv(Self.envKey, tmp, 1)
|
||||
|
||||
// Every derived path in HermesPathSet is computed off `home`, so
|
||||
// proving `home` flips is enough to guarantee state.db, config.yaml,
|
||||
// sessions/, cron/, scarf/projects.json, et al. all redirect.
|
||||
// We assert the registry path explicitly because that's the one
|
||||
// most likely to clobber the user's real ~/.hermes if the
|
||||
// override regresses.
|
||||
let paths = ServerContext.local.paths
|
||||
#expect(paths.home == tmp)
|
||||
#expect(paths.projectsRegistry == tmp + "/scarf/projects.json")
|
||||
#expect(paths.cronJobsJSON == tmp + "/cron/jobs.json")
|
||||
#expect(paths.configYAML == tmp + "/config.yaml")
|
||||
}
|
||||
|
||||
@Test func overrideUnsetReturnsToProductionHome() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
unsetenv(Self.envKey)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
// Without the override, `paths.home` resolves to the user's real
|
||||
// Hermes home (or the active profile under it). We don't assert
|
||||
// an exact path — we'd be encoding the test machine's username —
|
||||
// but we do assert the shape: an absolute path ending in
|
||||
// `/.hermes` (default profile) or containing `/profiles/`
|
||||
// (named profile).
|
||||
let paths = ServerContext.local.paths
|
||||
#expect(paths.home.hasPrefix("/"))
|
||||
#expect(paths.home.hasSuffix("/.hermes") || paths.home.contains("/.hermes/profiles/"))
|
||||
}
|
||||
|
||||
private func restore(_ saved: String?) {
|
||||
if let saved {
|
||||
setenv(Self.envKey, saved, 1)
|
||||
} else {
|
||||
unsetenv(Self.envKey)
|
||||
}
|
||||
HermesProfileResolver.invalidateCache()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// TemplateInstallUITests.swift
|
||||
// scarfUITests
|
||||
//
|
||||
// Layer B of the dogfooding-templates harness — the XCUITest layer that
|
||||
// drives Scarf end-to-end via the real UI. This file lands as a scaffold
|
||||
// in the v2.7 cycle: it exercises the launch-argument + env-var plumbing
|
||||
// (SCARF_HERMES_HOME, --scarf-test-mode) and proves the app reaches a
|
||||
// non-crashed state under those flags. Driving the full install /
|
||||
// configure / dashboard journey arrives in v2.8 alongside the
|
||||
// accessibility-identifier sweep — see Test-Harness.md on the wiki.
|
||||
//
|
||||
// The scaffold is deliberately small. Its job is to prove the harness
|
||||
// *can* run, so the next person extending it has a known-green starting
|
||||
// point. The contract for the next iteration: keep `tmpHermesHome()` and
|
||||
// `launchedApp()` as the two helpers every Layer B test calls; everything
|
||||
// else is per-test.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class TemplateInstallUITests: XCTestCase {
|
||||
|
||||
private var tmpHome: URL?
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Stop on first failure — XCUITest runs are linear and the failure
|
||||
// mode we care about ("the app launched in test mode and is
|
||||
// responsive") is not something a later test recovers from.
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Wipe any tmp Hermes home created during the test. Wrapped in a
|
||||
// try? because tearDown should never be the thing that masks a
|
||||
// real test failure — if the rmdir fails, we'd rather the test
|
||||
// pass and the tmp dir get garbage-collected by the OS than the
|
||||
// test fail for a reason unrelated to the assertion.
|
||||
if let tmpHome {
|
||||
try? FileManager.default.removeItem(at: tmpHome)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scaffold: launch Scarf with the harness's env var + launch argument
|
||||
/// and confirm the launch fires. Asserting on window existence
|
||||
/// would currently fail because the app's polling services
|
||||
/// (`ServerLiveStatusRegistry`, `HermesCapabilitiesStore`) crash on
|
||||
/// the IPC handshake when `SCARF_HERMES_HOME` points at an empty dir
|
||||
/// — they assume `gateway_state.json` and the Hermes binary's state
|
||||
/// dir are populated. A follow-up will pre-populate the tmp home
|
||||
/// with a minimal fixture (`config.yaml`, `auth.json`, empty
|
||||
/// `cron/jobs.json`) before the assertion gets re-enabled.
|
||||
///
|
||||
/// The test still earns its keep today: it proves the
|
||||
/// `XCUIApplication.launchArguments` + `launchEnvironment` plumbing
|
||||
/// reaches Scarf, and acts as the canonical "this is how Layer B
|
||||
/// tests start." Drop it if you re-architect the harness; otherwise
|
||||
/// keep it green until the fixture-Hermes-home work lands.
|
||||
///
|
||||
/// See [Test-Harness wiki page](https://github.com/awizemann/scarf/wiki/Test-Harness)
|
||||
/// for the rest of the rollout.
|
||||
@MainActor
|
||||
func testAppLaunchesUnderTestMode() throws {
|
||||
let home = try makeTmpHermesHome()
|
||||
tmpHome = home
|
||||
|
||||
let app = launchedApp(hermesHome: home)
|
||||
defer { app.terminate() }
|
||||
|
||||
// Verify the launch reached the XCUITest IPC handshake — i.e. the
|
||||
// app process was spawned and the test runner connected to it.
|
||||
// `app.state` is non-blocking and reports `.runningForeground`
|
||||
// once the process has handshaked. Anything past that requires
|
||||
// the fixture work above.
|
||||
XCTAssertNotEqual(
|
||||
app.state, .notRunning,
|
||||
"XCUITest could not start Scarf with --scarf-test-mode + SCARF_HERMES_HOME=\(home.path). The launchArguments / launchEnvironment plumbing has regressed."
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helpers (called from every Layer B test, keep the contract stable)
|
||||
|
||||
/// Build a launched `XCUIApplication` configured for the harness:
|
||||
/// - `--scarf-test-mode` launch argument (read by `TestModeFlags`).
|
||||
/// - `SCARF_HERMES_HOME` env var (read by `HermesProfileResolver`).
|
||||
///
|
||||
/// Mirroring this configuration exactly across every Layer B test
|
||||
/// means a single regression in either seam fails the whole suite
|
||||
/// loudly — the alternative is per-test launch configs that quietly
|
||||
/// drift apart and let bugs hide between them.
|
||||
@MainActor
|
||||
private func launchedApp(hermesHome: URL) -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["--scarf-test-mode"]
|
||||
app.launchEnvironment["SCARF_HERMES_HOME"] = hermesHome.path
|
||||
app.launch()
|
||||
return app
|
||||
}
|
||||
|
||||
/// Create a fresh, empty Hermes home dir for this test. The harness
|
||||
/// pattern is one home per test — never share across tests, since the
|
||||
/// installer writes to it and a leaked install from test A breaks
|
||||
/// test B's preconditions. The path lands under
|
||||
/// `NSTemporaryDirectory()` so the OS reaps it on reboot even if
|
||||
/// teardown skips.
|
||||
private func makeTmpHermesHome() throws -> URL {
|
||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let path = base.appendingPathComponent("scarf-uitest-home-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||
return path
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,81 @@
|
||||
# HackerNews Daily Digest — Agent Instructions
|
||||
|
||||
This project keeps a daily digest of HackerNews top stories filtered to the score threshold and (optional) topic keywords the user configured. The same instructions apply whether you're Hermes, Claude Code, Cursor, Codex, Aider, or any other agent that reads `AGENTS.md`.
|
||||
|
||||
## Project layout
|
||||
|
||||
- `.scarf/config.json` — **the source of truth for filter settings.** Written by Scarf's install/configure UI. Holds:
|
||||
- `values.min_score` (number, default 100) — minimum HN score to include.
|
||||
- `values.max_items` (number, default 15) — cap on items per digest run.
|
||||
- `values.topics` (array of strings, default `[]`) — keywords to mark in the digest. Empty array means "no topic highlighting; include every story above the score threshold."
|
||||
- `.scarf/manifest.json` — cached copy of `template.json`. Don't modify.
|
||||
- `digest.md` — append-only markdown log. Newest run at the top. Each run is a section with the ISO-8601 timestamp as the heading. Created on the first run if it doesn't exist.
|
||||
- `.scarf/dashboard.json` — Scarf dashboard. **Only the `value` fields of the three stat widgets and the `items` array of the "Top Stories" list widget should be updated.** The section titles, widget types, and structure must stay intact.
|
||||
|
||||
## How configuration works
|
||||
|
||||
The user configures this project through Scarf's UI — not by editing files directly. On install, a form asked them for the score threshold, item cap, and any topic keywords; those values landed in `.scarf/config.json`. They can edit those values any time via the **Configuration** button on the project dashboard header.
|
||||
|
||||
Read configuration like this (JSON, via whatever file-read tool you have):
|
||||
|
||||
```
|
||||
cat .scarf/config.json
|
||||
# → { "values": { "min_score": 100, "max_items": 15, "topics": ["rust", "ai"] }, ... }
|
||||
```
|
||||
|
||||
**Never** edit `.scarf/config.json` yourself. If the user asks "raise the score threshold" or "add a topic" in chat, tell them to open the Configuration button on the dashboard.
|
||||
|
||||
## First-run bootstrap
|
||||
|
||||
If `digest.md` doesn't exist, create it with a one-line header:
|
||||
|
||||
```
|
||||
# HackerNews Daily Digest
|
||||
|
||||
Newest run at the top. Each section is a single digest.
|
||||
```
|
||||
|
||||
## What to do when the cron job fires
|
||||
|
||||
The cron prompt Scarf registers for this project carries **absolute paths** (the installer substitutes `{{PROJECT_DIR}}` at install time) — you don't need to figure out the project's location yourself. Use whatever absolute paths appear in the prompt you received; if you're working in the project's interactive chat instead, the paths below are relative to the project root.
|
||||
|
||||
1. Read `.scarf/config.json`. Extract `values.min_score` (number), `values.max_items` (number), and `values.topics` (array). Apply defaults (100 / 15 / `[]`) for any missing field.
|
||||
2. Fetch `https://hacker-news.firebaseio.com/v0/topstories.json`. Take the first `max_items * 3` IDs — that gives headroom for the score filter to drop low-scorers without re-fetching.
|
||||
3. For each ID, fetch `https://hacker-news.firebaseio.com/v0/item/<id>.json`. Keep only items where:
|
||||
- `type == "story"`,
|
||||
- `score >= min_score`,
|
||||
- either `url` or `text` is non-null.
|
||||
4. Truncate the surviving list to `max_items`.
|
||||
5. If `topics` is non-empty, walk each surviving item and find the first keyword whose lowercase form is a substring of the lowercase title. Tag the item with that keyword in `[brackets]`. If no keyword matches, leave the item un-tagged.
|
||||
6. Build a digest section:
|
||||
```
|
||||
## <ISO-8601 timestamp>
|
||||
|
||||
- [<score>] <title> [<topic>]? — <url or https://news.ycombinator.com/item?id=<id>>
|
||||
- …
|
||||
```
|
||||
Use the HN comments URL when the item has no external `url`.
|
||||
7. Prepend the section to `digest.md` (newest at top).
|
||||
8. Update `.scarf/dashboard.json`:
|
||||
- `Top Story Score` stat widget: `value` = the highest score in your filtered list (or `0` if the list is empty).
|
||||
- `Items Tracked` stat widget: `value` = number of items in the filtered list.
|
||||
- `Last Run` stat widget: `value` = the ISO-8601 timestamp.
|
||||
- `Top Stories` list widget `items`: one entry per filtered story:
|
||||
- `text`: `"[<score>] <title>"`
|
||||
- `status`: `"ok"` if the story matched a topic, otherwise `"pending"`.
|
||||
9. If the cron job has a `deliver` target set, emit a one-line summary (`12 items, top score 487 — "<title>"`) as the agent's final response so the delivery mechanism picks it up.
|
||||
|
||||
## What not to do
|
||||
|
||||
- Don't modify the structure of `dashboard.json` (section titles, widget types, widget titles, `columns`). Only the values listed above are writable.
|
||||
- Don't edit `.scarf/config.json` — that's the user's responsibility via the Configuration UI.
|
||||
- Don't truncate `digest.md` — it's the historical record. If it grows past 1 MB, add a one-line note at the top of the file asking the user to archive it.
|
||||
- Don't fetch any URL other than `hacker-news.firebaseio.com` (the digest source) or the items the user explicitly asks about. No scraping, no other news sources.
|
||||
- Don't paginate past the first `max_items * 3` IDs. If the score filter eats all of them, write an empty digest section noting "no stories above threshold today" and update widgets to zero.
|
||||
|
||||
## When the user asks you things
|
||||
|
||||
- "What's in today's digest?" — read the top section of `digest.md` and summarize.
|
||||
- "Run the digest now" — do everything in the cron flow above, then summarize the results in chat.
|
||||
- "Why is [story] not in the digest?" — read the last 3–5 sections of `digest.md` and check whether the story appeared. If not, suggest the most likely cause (score below threshold, item type wasn't `story`, item appeared after the most recent run).
|
||||
- "Change the threshold" / "add a topic" — tell them: *"Click the Configuration button on the dashboard header (the slider icon, next to the folder). Adjust the values there and save. The next cron run will pick it up."* Don't try to edit config.json yourself.
|
||||
@@ -0,0 +1,40 @@
|
||||
# HackerNews Daily Digest
|
||||
|
||||
A minimal news-aggregation project that fetches HackerNews top stories once a day, filters them by score and (optional) topic keywords, and keeps a rolling markdown log + a live Scarf dashboard.
|
||||
|
||||
**Requires Scarf 2.3+** — uses the Configuration form during install and on-demand re-edit.
|
||||
|
||||
## What you get
|
||||
|
||||
- **Configurable score threshold** — only stories at or above this score show up. HN front page averages ~150; lower it to widen the net, raise it to focus on the truly viral.
|
||||
- **Configurable item cap** — keeps each digest from sprawling. Default 15.
|
||||
- **Optional topic keywords** — a list of keywords (case-insensitive substring match against titles). Items that match a keyword get a `[topic]` tag in the digest and `"ok"` status in the dashboard list. Empty list = include every story above threshold, no highlighting.
|
||||
- **No API keys** — HackerNews' Firebase API is fully public. Nothing in this project's `.scarf/config.json` is secret; no Keychain entries are created.
|
||||
- **`digest.md`** — agent's append-only log. New runs prepend at the top. Created automatically on first run.
|
||||
- **`.scarf/dashboard.json`** — live dashboard with stat widgets (top score, items tracked, last run) and a Top Stories list.
|
||||
- **Cron job `Daily HN digest`** — registered (paused) by the installer; tag `[tmpl:awizemann/hackernews-digest]`. Runs daily at 8:00 AM when enabled.
|
||||
|
||||
## First steps
|
||||
|
||||
1. During install, fill in the Configuration form — set `min_score`, `max_items`, and any topic keywords you care about. (All have sensible defaults if you just want to skip it.) Hit Continue, then Install.
|
||||
2. After install, open the **Cron** sidebar and enable the `[tmpl:awizemann/hackernews-digest] Daily HN digest` job. It's paused on install so nothing runs without your explicit say-so.
|
||||
3. From the project's dashboard, ask your agent to run the job now: *"Run the HN digest and update the dashboard."*
|
||||
4. Future runs happen automatically at 8 AM daily.
|
||||
|
||||
## Changing filters later
|
||||
|
||||
Click the **Configuration** button (slider icon, dashboard toolbar) to re-open the form pre-filled with your current values. Adjust score, max items, or topics. Save. The next cron run picks up the changes.
|
||||
|
||||
## Customizing
|
||||
|
||||
- **Change the schedule.** Edit the cron job in the Cron sidebar — accepts `30m`, `every 2h`, or standard cron expressions like `0 8 * * *`.
|
||||
- **Switch sources.** This template is HN-only by design. To pull from Lobsters, Reddit, or RSS, fork it (export from a Scarf project, edit `cron/jobs.json`'s prompt, re-import) — most of the agent contract is generic.
|
||||
- **Add alerting.** Set a `deliver` target on the cron job (Discord, Slack, Telegram) — the agent will post the run summary there instead of just writing to `digest.md`.
|
||||
|
||||
## Recommended model
|
||||
|
||||
`claude-haiku-4` works well — this is a simple HTTP-fetch + filter + markdown task. Haiku keeps costs low when the cron runs daily. The recommendation appears in the Configuration form; Scarf doesn't auto-switch your active model, so adjust via Settings if you'd like.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Right-click the project in the sidebar → **Uninstall Template…** (or click the shippingbox icon on the dashboard header). Scarf walks you through exactly what's about to be removed: template-installed files in the project dir, the `[tmpl:…]` cron job, and the configuration values you entered (`config.json`; this template stores no secrets so there's nothing in Keychain to clean up). User-created files (like `digest.md`) are preserved.
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "Daily HN digest",
|
||||
"schedule": "0 8 * * *",
|
||||
"prompt": "Generate the HackerNews daily digest for the Scarf project at {{PROJECT_DIR}}. Read {{PROJECT_DIR}}/.scarf/config.json to get `values.min_score` (number, default 100), `values.max_items` (number, default 15), and `values.topics` (array of strings, default []). Fetch the top story IDs from https://hacker-news.firebaseio.com/v0/topstories.json and take the first `max_items * 3` IDs (gives headroom for the score filter to drop low-scorers). For each ID, fetch https://hacker-news.firebaseio.com/v0/item/<id>.json and keep only `type==\"story\"` items with `score >= min_score` and a non-null `url` or `text`. Cap the surviving list at `max_items`. If `topics` is non-empty, mark each surviving item with a `[topic]` tag for the first matching keyword (case-insensitive substring match against the title). Build a markdown digest section with the ISO-8601 timestamp as the heading and one bullet per item (`- [<score>] <title> [<topic>]? — <url or HN comments link>`). Prepend that section to {{PROJECT_DIR}}/digest.md (create the file with a one-line header if it doesn't exist). Update {{PROJECT_DIR}}/.scarf/dashboard.json: set the `Top Story Score` stat widget's `value` to the highest score, the `Items Tracked` stat widget's `value` to the count of items, and the `Last Run` stat widget's `value` to the ISO-8601 timestamp. Replace the `Top Stories` list widget's `items` array with one entry per item (text = `[<score>] <title>`, status = `\"ok\"` if the item has a topic match else `\"pending\"`). Preserve every other field in dashboard.json as-is. Reply with a one-line summary like '12 items, top score 487 — \"<title>\"'."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"version": 1,
|
||||
"title": "HackerNews Digest",
|
||||
"description": "A daily roll-up of HackerNews top stories above your configured score threshold. The stat widgets and Top Stories list update each time the cron job runs; the digest itself is prepended to `digest.md` in the project root.",
|
||||
"theme": { "accent": "orange" },
|
||||
"sections": [
|
||||
{
|
||||
"title": "Today's Digest",
|
||||
"columns": 3,
|
||||
"widgets": [
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Top Story Score",
|
||||
"value": 0,
|
||||
"icon": "flame.fill",
|
||||
"color": "orange",
|
||||
"subtitle": "highest-scoring item"
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Items Tracked",
|
||||
"value": 0,
|
||||
"icon": "list.bullet.rectangle",
|
||||
"color": "blue",
|
||||
"subtitle": "above your score threshold"
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Last Run",
|
||||
"value": "never",
|
||||
"icon": "clock",
|
||||
"color": "gray",
|
||||
"subtitle": "ISO-8601 timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Top Stories",
|
||||
"columns": 1,
|
||||
"widgets": [
|
||||
{
|
||||
"type": "list",
|
||||
"title": "Top Stories (populated after first run)",
|
||||
"items": [
|
||||
{ "text": "Run the digest once to populate — the agent reads your Configuration, fetches HackerNews' top stories, and fills this list.", "status": "pending" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "How to Use",
|
||||
"columns": 1,
|
||||
"widgets": [
|
||||
{
|
||||
"type": "text",
|
||||
"title": "Quick Start",
|
||||
"format": "markdown",
|
||||
"content": "**1.** Review your configuration — click the **slider icon** (top-right of this dashboard) to open Configuration. Set `min_score`, `max_items`, and any `topics` keywords you want highlighted.\n\n**2.** Enable the `[tmpl:awizemann/hackernews-digest] Daily HN digest` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**3.** Ask your agent: *\"Run the HN digest now.\"* The Top Stories list populates, the stat widgets update, and a fresh entry lands at the top of `digest.md`.\n\n**4.** Daily at 8 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"id": "awizemann/hackernews-digest",
|
||||
"name": "HackerNews Daily Digest",
|
||||
"version": "1.0.0",
|
||||
"minScarfVersion": "2.3.0",
|
||||
"minHermesVersion": "0.9.0",
|
||||
"author": {
|
||||
"name": "Alan Wizemann",
|
||||
"url": "https://github.com/awizemann"
|
||||
},
|
||||
"description": "A daily digest of HackerNews top stories. Pulls Hacker News' Firebase API, filters by minimum score and optional topics, prepends a markdown digest to digest.md, and keeps the dashboard's top stories list current. No API keys required.",
|
||||
"category": "news",
|
||||
"tags": ["news", "digest", "hackernews", "cron", "starter", "configurable"],
|
||||
"contents": {
|
||||
"dashboard": true,
|
||||
"agentsMd": true,
|
||||
"cron": 1,
|
||||
"config": 3
|
||||
},
|
||||
"config": {
|
||||
"schema": [
|
||||
{
|
||||
"key": "topics",
|
||||
"type": "list",
|
||||
"itemType": "string",
|
||||
"label": "Highlight Topics (optional)",
|
||||
"description": "Keywords or phrases to highlight in the digest (case-insensitive substring match against story titles). Leave empty to include every top story above the score threshold.",
|
||||
"required": false,
|
||||
"minItems": 0,
|
||||
"maxItems": 20,
|
||||
"default": []
|
||||
},
|
||||
{
|
||||
"key": "min_score",
|
||||
"type": "number",
|
||||
"label": "Minimum Score",
|
||||
"description": "Only include stories at or above this point score. HN's front page averages ~150; lower this to widen the net, raise it to focus on viral-only items.",
|
||||
"required": false,
|
||||
"min": 1,
|
||||
"max": 1000,
|
||||
"default": 100
|
||||
},
|
||||
{
|
||||
"key": "max_items",
|
||||
"type": "number",
|
||||
"label": "Maximum Items",
|
||||
"description": "Cap on how many stories appear in each digest. Avoids blowing up the dashboard list when HN has a busy day.",
|
||||
"required": false,
|
||||
"min": 5,
|
||||
"max": 50,
|
||||
"default": 15
|
||||
}
|
||||
],
|
||||
"modelRecommendation": {
|
||||
"preferred": "claude-haiku-4",
|
||||
"rationale": "Simple HTTP fetch + filter + markdown render. Haiku is plenty fast and the cheapest option for a daily run."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,76 @@
|
||||
"generated": true,
|
||||
"schemaVersion": 1,
|
||||
"templates": [
|
||||
{
|
||||
"author": {
|
||||
"name": "Alan Wizemann",
|
||||
"url": "https://github.com/awizemann"
|
||||
},
|
||||
"bundleSha256": "4889bc63c25e928ce96cf4032f248435348ee72d3b9c30ae5282361605a8616d",
|
||||
"bundleSize": 8049,
|
||||
"category": "news",
|
||||
"config": {
|
||||
"modelRecommendation": {
|
||||
"preferred": "claude-haiku-4",
|
||||
"rationale": "Simple HTTP fetch + filter + markdown render. Haiku is plenty fast and the cheapest option for a daily run."
|
||||
},
|
||||
"schema": [
|
||||
{
|
||||
"default": [],
|
||||
"description": "Keywords or phrases to highlight in the digest (case-insensitive substring match against story titles). Leave empty to include every top story above the score threshold.",
|
||||
"itemType": "string",
|
||||
"key": "topics",
|
||||
"label": "Highlight Topics (optional)",
|
||||
"maxItems": 20,
|
||||
"minItems": 0,
|
||||
"required": false,
|
||||
"type": "list"
|
||||
},
|
||||
{
|
||||
"default": 100,
|
||||
"description": "Only include stories at or above this point score. HN's front page averages ~150; lower this to widen the net, raise it to focus on viral-only items.",
|
||||
"key": "min_score",
|
||||
"label": "Minimum Score",
|
||||
"max": 1000,
|
||||
"min": 1,
|
||||
"required": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"default": 15,
|
||||
"description": "Cap on how many stories appear in each digest. Avoids blowing up the dashboard list when HN has a busy day.",
|
||||
"key": "max_items",
|
||||
"label": "Maximum Items",
|
||||
"max": 50,
|
||||
"min": 5,
|
||||
"required": false,
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"contents": {
|
||||
"agentsMd": true,
|
||||
"config": 3,
|
||||
"cron": 1,
|
||||
"dashboard": true
|
||||
},
|
||||
"description": "A daily digest of HackerNews top stories. Pulls Hacker News' Firebase API, filters by minimum score and optional topics, prepends a markdown digest to digest.md, and keeps the dashboard's top stories list current. No API keys required.",
|
||||
"detailSlug": "awizemann-hackernews-digest",
|
||||
"id": "awizemann/hackernews-digest",
|
||||
"installUrl": "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/hackernews-digest/hackernews-digest.scarftemplate",
|
||||
"minHermesVersion": "0.9.0",
|
||||
"minScarfVersion": "2.3.0",
|
||||
"name": "HackerNews Daily Digest",
|
||||
"tags": [
|
||||
"news",
|
||||
"digest",
|
||||
"hackernews",
|
||||
"cron",
|
||||
"starter",
|
||||
"configurable"
|
||||
],
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"author": {
|
||||
"name": "Alan Wizemann",
|
||||
|
||||
Reference in New Issue
Block a user